@unchainedshop/api 5.0.0-alpha.2 → 5.0.0-alpha.3

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 (97) hide show
  1. package/README.md +6 -4
  2. package/lib/adminUiPlugins.d.ts +73 -0
  3. package/lib/adminUiPlugins.js +164 -0
  4. package/lib/adminUiTheme.d.ts +25 -0
  5. package/lib/adminUiTheme.js +34 -0
  6. package/lib/chat/generateImageHandler.d.ts +273 -7
  7. package/lib/chat/utils.d.ts +8 -0
  8. package/lib/chat/utils.js +54 -2
  9. package/lib/createGraphQLServer.js +6 -0
  10. package/lib/express/chatHandler.js +52 -61
  11. package/lib/express/createMCPMiddleware.js +19 -78
  12. package/lib/express/index.d.ts +8 -2
  13. package/lib/express/index.js +55 -7
  14. package/lib/fastify/chatHandler.js +52 -61
  15. package/lib/fastify/index.d.ts +7 -1
  16. package/lib/fastify/index.js +58 -11
  17. package/lib/fastify/mcpHandler.js +24 -75
  18. package/lib/loaders/cartLoader.d.ts +13 -0
  19. package/lib/loaders/cartLoader.js +8 -0
  20. package/lib/loaders/index.d.ts +9 -0
  21. package/lib/loaders/index.js +2 -0
  22. package/lib/locale-context.d.ts +2 -4
  23. package/lib/locale-context.js +2 -1
  24. package/lib/mcp/handleMcpHttpRequest.d.ts +2 -0
  25. package/lib/mcp/handleMcpHttpRequest.js +83 -0
  26. package/lib/mcp/index.d.ts +1 -1
  27. package/lib/mcp/nodeHttpBridge.d.ts +3 -0
  28. package/lib/mcp/nodeHttpBridge.js +64 -0
  29. package/lib/mcp/resources/localization.d.ts +5 -1
  30. package/lib/mcp/resources/localization.js +93 -69
  31. package/lib/mcp/tools/assortment/index.d.ts +1 -1
  32. package/lib/mcp/tools/assortment/index.js +4 -1
  33. package/lib/mcp/tools/assortment/schemas.d.ts +2 -2
  34. package/lib/mcp/tools/filter/handlers/createFilter.js +2 -4
  35. package/lib/mcp/tools/filter/handlers/createFilterOption.js +2 -4
  36. package/lib/mcp/tools/filter/handlers/removeFilterOption.js +2 -4
  37. package/lib/mcp/tools/filter/handlers/updateFilter.js +2 -2
  38. package/lib/mcp/tools/filter/index.d.ts +1 -1
  39. package/lib/mcp/tools/filter/index.js +4 -1
  40. package/lib/mcp/tools/filter/schemas.d.ts +2 -2
  41. package/lib/mcp/tools/localization/index.d.ts +1 -1
  42. package/lib/mcp/tools/localization/index.js +4 -1
  43. package/lib/mcp/tools/localization/schemas.d.ts +2 -2
  44. package/lib/mcp/tools/order/index.d.ts +1 -1
  45. package/lib/mcp/tools/order/index.js +4 -1
  46. package/lib/mcp/tools/order/schemas.d.ts +2 -2
  47. package/lib/mcp/tools/product/index.d.ts +1 -1
  48. package/lib/mcp/tools/product/index.js +4 -1
  49. package/lib/mcp/tools/product/schemas.d.ts +7 -7
  50. package/lib/mcp/tools/product/schemas.js +3 -2
  51. package/lib/mcp/tools/provider/index.d.ts +1 -1
  52. package/lib/mcp/tools/provider/index.js +4 -1
  53. package/lib/mcp/tools/provider/schemas.d.ts +2 -2
  54. package/lib/mcp/tools/quotation/index.d.ts +1 -1
  55. package/lib/mcp/tools/quotation/index.js +4 -1
  56. package/lib/mcp/tools/quotation/schemas.d.ts +2 -2
  57. package/lib/mcp/tools/system/index.d.ts +1 -1
  58. package/lib/mcp/tools/system/index.js +4 -1
  59. package/lib/mcp/tools/users/index.d.ts +1 -1
  60. package/lib/mcp/tools/users/index.js +4 -1
  61. package/lib/mcp/tools/users/schemas.d.ts +2 -2
  62. package/lib/mcp/utils/sharedSchemas.d.ts +1 -0
  63. package/lib/mcp/utils/sharedSchemas.js +1 -0
  64. package/lib/resolvers/index.js +2 -1
  65. package/lib/resolvers/mutations/bookmarks/createBookmark.d.ts +1 -1
  66. package/lib/resolvers/mutations/bookmarks/createBookmark.js +4 -3
  67. package/lib/resolvers/mutations/filters/createFilter.js +3 -4
  68. package/lib/resolvers/mutations/filters/createFilterOption.d.ts +1 -1
  69. package/lib/resolvers/mutations/filters/createFilterOption.js +3 -4
  70. package/lib/resolvers/mutations/filters/removeFilterOption.d.ts +1 -1
  71. package/lib/resolvers/mutations/filters/removeFilterOption.js +2 -8
  72. package/lib/resolvers/mutations/filters/updateFilter.d.ts +1 -1
  73. package/lib/resolvers/mutations/filters/updateFilter.js +2 -5
  74. package/lib/resolvers/mutations/index.js +3 -3
  75. package/lib/resolvers/mutations/products/updateProductCommerce.js +10 -1
  76. package/lib/resolvers/scalars/LowerCaseString.d.ts +1 -1
  77. package/lib/resolvers/scalars/index.d.ts +1 -1
  78. package/lib/resolvers/type/filter/loaded-filter-types.d.ts +1 -1
  79. package/lib/resolvers/type/filter/loaded-filter-types.js +1 -1
  80. package/lib/resolvers/type/index.d.ts +9 -5
  81. package/lib/resolvers/type/product/product-bundle-types.d.ts +1 -1
  82. package/lib/resolvers/type/product/product-plan-types.d.ts +1 -1
  83. package/lib/resolvers/type/product/product-simple-types.d.ts +1 -1
  84. package/lib/resolvers/type/product/product-tokenized-types.d.ts +1 -1
  85. package/lib/resolvers/type/quotation-types.d.ts +4 -0
  86. package/lib/resolvers/type/quotation-types.js +1 -0
  87. package/lib/resolvers/type/user-types.js +2 -2
  88. package/lib/roles/index.js +2 -0
  89. package/lib/roles/loggedIn.js +11 -0
  90. package/lib/schema/inputTypes.js +3 -3
  91. package/lib/schema/scalars.js +1 -0
  92. package/lib/schema/types/filter.js +2 -2
  93. package/lib/schema/types/product/product.js +1 -1
  94. package/lib/schema/types/quotation.js +5 -0
  95. package/lib/utils/optionalPeerError.d.ts +1 -0
  96. package/lib/utils/optionalPeerError.js +6 -0
  97. package/package.json +37 -13
@@ -1,89 +1,60 @@
1
1
  import generateImageHandler from "../chat/generateImageHandler.js";
2
2
  import defaultSystemPrompt from "../chat/defaultSystemPrompt.js";
3
3
  import normalizeToolsIndex from "../chat/normalizeToolsIndex.js";
4
- import { errorHandler } from "../chat/utils.js";
4
+ import { chatErrorStatus, createChatRequestLifecycle, errorHandler, logOptionalPeerLoadError, } from "../chat/utils.js";
5
+ import { buildChatResourceContext } from "../mcp/resources/localization.js";
5
6
  import { createLogger } from '@unchainedshop/logger';
6
7
  const logger = createLogger('unchained:api:chat');
7
8
  let convertToModelMessages;
8
9
  let stepCountIs;
9
10
  let streamText;
10
11
  let createMCPClient;
11
- let StreamableHTTPClientTransport;
12
- let Client;
13
12
  try {
14
13
  const aiTools = await import('ai');
15
- const mcpTools = await import('@ai-sdk/mcp');
16
- const mcpSDKClientLibrary = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
17
- const mcpSDKClient = await import('@modelcontextprotocol/sdk/client/index.js');
18
- StreamableHTTPClientTransport = mcpSDKClientLibrary.StreamableHTTPClientTransport;
19
- Client = mcpSDKClient.Client;
20
14
  convertToModelMessages = aiTools.convertToModelMessages;
21
15
  stepCountIs = aiTools.stepCountIs;
22
16
  streamText = aiTools.streamText;
17
+ }
18
+ catch (error) {
19
+ logOptionalPeerLoadError('ai', error);
20
+ }
21
+ try {
22
+ const mcpTools = await import('@ai-sdk/mcp');
23
23
  createMCPClient = mcpTools.createMCPClient;
24
24
  }
25
- catch {
26
- logger.warn(`optional peer npm packages 'ai' and '@ai-sdk/mcp' not installed, chat will not work`);
25
+ catch (error) {
26
+ logOptionalPeerLoadError('@ai-sdk/mcp', error);
27
27
  }
28
28
  const setupMCPChatHandler = (chatConfiguration) => {
29
29
  if (!chatConfiguration?.model) {
30
30
  throw new Error('Model is required');
31
31
  }
32
- const { tools: additionalTools = {}, unchainedMCPUrl = `${process.env.ROOT_URL}/mcp`, imageGenerationTool, ...restChatConfig } = chatConfiguration;
32
+ const { tools: additionalTools = {}, unchainedMCPUrl = `${process.env.ROOT_URL}/mcp`, model, imageGenerationTool, abortSignal: configuredAbortSignal, onAbort: configuredOnAbort, onEnd: configuredOnEnd, onFinish: configuredOnFinish, ...restChatConfig } = chatConfiguration;
33
33
  const system = chatConfiguration.system ?? defaultSystemPrompt;
34
34
  const mcpChatHandler = async (req, res) => {
35
+ if (req.method === 'OPTIONS') {
36
+ res.headers({
37
+ 'access-control-allow-credentials': 'true',
38
+ 'access-control-allow-private-network': 'true',
39
+ });
40
+ return res.status(200).send();
41
+ }
35
42
  let client;
43
+ const lifecycle = createChatRequestLifecycle(res.raw, configuredAbortSignal);
36
44
  try {
37
- if (req.method === 'OPTIONS') {
38
- res.headers({
39
- 'access-control-allow-credentials': 'true',
40
- 'access-control-allow-private-network': 'true',
41
- });
42
- return res.status(200).send();
43
- }
44
- const resourceTransport = new StreamableHTTPClientTransport(new URL(unchainedMCPUrl), {
45
- requestInit: {
46
- headers: {
47
- Cookie: req.headers.cookie || '',
48
- },
49
- },
50
- });
51
- const sdkClient = new Client({ name: 'unchained-chat-client', version: '1.0.0' });
52
- await sdkClient.connect(resourceTransport);
53
- const transport = new StreamableHTTPClientTransport(new URL(unchainedMCPUrl), {
54
- requestInit: {
45
+ client = await createMCPClient({
46
+ transport: {
47
+ type: 'http',
48
+ url: unchainedMCPUrl,
55
49
  headers: {
56
50
  Cookie: req.headers.cookie || '',
57
51
  },
58
52
  },
53
+ initializationOptions: { signal: lifecycle.signal },
59
54
  });
60
- client = await createMCPClient({
61
- transport,
62
- });
55
+ lifecycle.setClientClose(() => client.close());
63
56
  const defaultUnchainedTools = await client.tools();
64
- let resourceContext = '';
65
- try {
66
- const resources = await sdkClient.listResources();
67
- if (resources?.resources) {
68
- const resourceTexts = await Promise.all(resources.resources.map(async (resource) => {
69
- try {
70
- const content = await sdkClient.readResource({ uri: resource.uri });
71
- if (content?.contents?.[0]?.text) {
72
- return `${resource.name}:\n${content.contents[0].text}`;
73
- }
74
- }
75
- catch (e) {
76
- logger.error(`Failed to read resource ${resource.uri}: ${e.message}`);
77
- }
78
- return null;
79
- }));
80
- resourceContext =
81
- '\n\nAVAILABLE SHOP CONFIGURATION:\n' + resourceTexts.filter(Boolean).join('\n\n');
82
- }
83
- }
84
- catch (e) {
85
- logger.error(`Failed to fetch MCP resources: ${e.message}`);
86
- }
57
+ const resourceContext = await buildChatResourceContext(req.unchainedContext);
87
58
  const tools = {
88
59
  ...defaultUnchainedTools,
89
60
  ...additionalTools,
@@ -92,6 +63,7 @@ const setupMCPChatHandler = (chatConfiguration) => {
92
63
  tools.generateImage = generateImageHandler(req)(imageGenerationTool);
93
64
  }
94
65
  if (req.method === 'GET') {
66
+ await lifecycle.close();
95
67
  return res.status(200).send({
96
68
  tools: normalizeToolsIndex(tools),
97
69
  cached: false,
@@ -127,14 +99,28 @@ const setupMCPChatHandler = (chatConfiguration) => {
127
99
  const messagesToInclude = normalizedMessages.slice(startIndex);
128
100
  const result = streamText({
129
101
  stopWhen: stepCountIs(500),
130
- temperature: 0.2,
131
102
  maxRetries: 3,
132
103
  ...restChatConfig,
104
+ abortSignal: lifecycle.signal,
133
105
  messages: messagesToInclude,
134
106
  system: system + resourceContext,
107
+ model,
135
108
  tools: cacheControlledTools,
136
- onFinish: async () => {
137
- await client?.close();
109
+ onEnd: async (event) => {
110
+ try {
111
+ await (configuredOnEnd ?? configuredOnFinish)?.(event);
112
+ }
113
+ finally {
114
+ await lifecycle.close();
115
+ }
116
+ },
117
+ onAbort: async (event) => {
118
+ try {
119
+ await configuredOnAbort?.(event);
120
+ }
121
+ finally {
122
+ await lifecycle.close();
123
+ }
138
124
  },
139
125
  providerOptions: {
140
126
  anthropic: {
@@ -144,21 +130,26 @@ const setupMCPChatHandler = (chatConfiguration) => {
144
130
  },
145
131
  },
146
132
  });
133
+ void result.finishReason.then(undefined, async () => {
134
+ await lifecycle.close();
135
+ });
147
136
  return res.send(result.toUIMessageStreamResponse({
148
137
  onError: errorHandler,
149
138
  }));
150
139
  }
151
140
  catch (err) {
152
141
  logger.error(err);
153
- await client?.close();
154
- res.status(500);
142
+ await lifecycle.close();
143
+ if (lifecycle.signal.aborted || res.raw.destroyed)
144
+ return res;
145
+ res.status(chatErrorStatus(err));
155
146
  return res.send({ error: errorHandler(err) });
156
147
  }
157
148
  };
158
149
  return mcpChatHandler;
159
150
  };
160
151
  export const connectChat = (app, chatConfiguration) => {
161
- if (!createMCPClient) {
152
+ if (!createMCPClient || !streamText) {
162
153
  logger.warn('Optional dependencies for AI SDK Chat Handler are not installed. Please install @ai-sdk/mcp and ai packages to use this feature.');
163
154
  return;
164
155
  }
@@ -4,9 +4,15 @@ import type { UnchainedCore } from '@unchainedshop/core';
4
4
  import type { FastifyBaseLogger, FastifyInstance, FastifyPluginAsync } from 'fastify';
5
5
  import { connectChat } from './chatHandler.ts';
6
6
  import type { ChatConfiguration } from '../chat/utils.ts';
7
+ import { type AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme';
8
+ import { type AdminUIPluginConfig } from '../adminUiPlugins.ts';
9
+ export type { AdminUIPluginConfig, AdminUIPluginEntityConfig, AdminUIPluginPageConfig, AdminUIPluginTabConfig, AdminUIPluginWidgetConfig, AdminUIPluginSlotConfig, } from '../adminUiPlugins.ts';
10
+ export type { AdminUIThemeTokens, AdminUIThemeConfig } from '@unchainedshop/admin-ui/theme';
7
11
  export interface AdminUIRouterOptions {
8
- prefix: string;
12
+ prefix?: string;
9
13
  enabled?: boolean;
14
+ theme?: AdminUIThemeConfig;
15
+ plugins?: AdminUIPluginConfig[];
10
16
  }
11
17
  export declare const unchainedLogger: (prefix: string) => FastifyBaseLogger;
12
18
  export declare const connect: (fastify: FastifyInstance, { graphqlHandler, unchainedAPI, }: {
@@ -6,8 +6,12 @@ import { createLogger } from '@unchainedshop/logger';
6
6
  import mcpHandler from "./mcpHandler.js";
7
7
  import { connectChat } from "./chatHandler.js";
8
8
  import { mountRoutes } from "./mountRoutes.js";
9
- import { readFileSync } from 'node:fs';
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
10
12
  import { createBackchannelLogoutRoute } from "../handlers/createBackchannelLogoutHandler.js";
13
+ import { generateThemeCSS } from '@unchainedshop/admin-ui/theme';
14
+ import { preparePluginAssets, resolveAdminUIPath } from "../adminUiPlugins.js";
11
15
  const resolveUserRemoteAddress = (req, trustProxy = false) => {
12
16
  let remoteAddress;
13
17
  if (trustProxy) {
@@ -120,9 +124,47 @@ export const connect = async (fastify, { graphqlHandler, unchainedAPI, }, { allo
120
124
  }
121
125
  mountRoutes(fastify, unchainedAPI, routes);
122
126
  if (adminUI) {
127
+ const adminUIOptions = typeof adminUI === 'object' ? adminUI : undefined;
128
+ const adminUIPlugins = adminUIOptions?.plugins || [];
129
+ const themeCSS = generateThemeCSS(adminUIOptions?.theme);
130
+ const themeHash = createHash('sha256').update(themeCSS).digest('hex').slice(0, 8);
131
+ const themeEtag = `"${themeHash}"`;
132
+ fastify.get('/admin-ui-theme.css', async (request, reply) => {
133
+ if (request.headers['if-none-match'] === themeEtag) {
134
+ return reply.code(304).send();
135
+ }
136
+ return reply
137
+ .header('Cache-Control', 'public, max-age=0, must-revalidate')
138
+ .header('ETag', themeEtag)
139
+ .type('text/css')
140
+ .send(themeCSS);
141
+ });
142
+ const devMode = process.env.NODE_ENV !== 'production';
143
+ const { routes: pluginRoutes } = preparePluginAssets(adminUIPlugins, fastify.log, {
144
+ devMode,
145
+ });
146
+ for (const [path, asset] of pluginRoutes) {
147
+ fastify.get(path, async (request, reply) => {
148
+ if (asset.etag) {
149
+ const ifNoneMatch = request.headers['if-none-match'];
150
+ if (ifNoneMatch === asset.etag) {
151
+ return reply.code(304).send();
152
+ }
153
+ reply.header('ETag', asset.etag);
154
+ }
155
+ const body = typeof asset.content === 'function' ? asset.content() : asset.content;
156
+ return reply
157
+ .header('Cache-Control', asset.cacheControl)
158
+ .header('X-Content-Type-Options', 'nosniff')
159
+ .header('Access-Control-Allow-Origin', '*')
160
+ .type(asset.contentType)
161
+ .send(body);
162
+ });
163
+ }
123
164
  fastify.register(adminUIRouter, {
124
165
  enabled: true,
125
- prefix: typeof adminUI === 'object' ? adminUI.prefix : '/',
166
+ prefix: adminUIOptions?.prefix || '/',
167
+ plugins: adminUIPlugins,
126
168
  });
127
169
  }
128
170
  };
@@ -142,15 +184,6 @@ const fallbackLandingPageHandler = (request, reply) => {
142
184
  return reply.status(404).send();
143
185
  }
144
186
  };
145
- const resolveAdminUIPath = () => {
146
- try {
147
- const staticURL = import.meta.resolve('@unchainedshop/admin-ui');
148
- return new URL(staticURL).pathname.split('/').slice(0, -1).join('/');
149
- }
150
- catch {
151
- return null;
152
- }
153
- };
154
187
  export const adminUIRouter = async (fastify, opts) => {
155
188
  try {
156
189
  let fastifyStatic;
@@ -164,9 +197,23 @@ export const adminUIRouter = async (fastify, opts) => {
164
197
  if (fastifyStatic) {
165
198
  const adminUIPath = resolveAdminUIPath();
166
199
  if (adminUIPath) {
200
+ const indexHtml = readFileSync(join(adminUIPath, 'index.html'), 'utf-8');
201
+ const extHtmlPath = join(adminUIPath, 'ext', '[[...slug]]', 'index.html');
202
+ const extHtml = existsSync(extHtmlPath) ? readFileSync(extHtmlPath, 'utf-8') : null;
167
203
  await fastify.register(fastifyStatic, {
168
204
  root: adminUIPath,
169
205
  prefix: opts.prefix || '/',
206
+ wildcard: false,
207
+ });
208
+ fastify.setNotFoundHandler(async (request, reply) => {
209
+ if (request.method === 'GET' && !request.url.includes('.')) {
210
+ const urlPath = request.url.split('?')[0].replace(/\/+$/, '');
211
+ if (extHtml && (urlPath === '/ext' || urlPath.startsWith('/ext/'))) {
212
+ return reply.type('text/html').send(extHtml);
213
+ }
214
+ return reply.type('text/html').send(indexHtml);
215
+ }
216
+ return reply.code(404).send({ error: 'Not Found' });
170
217
  });
171
218
  return;
172
219
  }
@@ -1,83 +1,32 @@
1
- import initMCPServer from "../mcp/index.js";
1
+ import { Readable } from 'node:stream';
2
2
  import { createLogger } from '@unchainedshop/logger';
3
+ import handleMcpHttpRequest from "../mcp/handleMcpHttpRequest.js";
4
+ import { toWebRequest } from "../mcp/nodeHttpBridge.js";
3
5
  const logger = createLogger('unchained:api:mcp');
4
- let StreamableHTTPServerTransport;
5
- let isInitializeRequest;
6
- let McpServer;
7
- try {
8
- const mcpSDKServerLibrary = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
9
- const mcpSDKClient = await import('@modelcontextprotocol/sdk/types.js');
10
- const mcpSDKServer = await import('@modelcontextprotocol/sdk/server/mcp.js');
11
- StreamableHTTPServerTransport = mcpSDKServerLibrary.StreamableHTTPServerTransport;
12
- isInitializeRequest = mcpSDKClient.isInitializeRequest;
13
- McpServer = mcpSDKServer.McpServer;
14
- }
15
- catch {
16
- logger.warn(`optional peer npm package '@modelcontextprotocol/sdk' not installed, mcp will not work`);
17
- }
18
- const transports = {};
19
6
  const mcpHandler = async (req, res) => {
20
- if (!req.unchainedContext.user) {
21
- res.status(401);
22
- res.header('WWW-Authenticate', `Bearer realm="Unchained MCP", error="invalid_token", resource="${process.env.ROOT_URL || 'http://localhost:4010'}",`);
23
- return res.send(JSON.stringify({
24
- error: 'invalid_token',
25
- resource_metadata: `${process.env.ROOT_URL || 'http://localhost:4010'}/.well-known/oauth-protected-resource`,
26
- }));
27
- }
28
7
  try {
29
- if (req.method === 'POST') {
30
- const sessionId = req.headers['mcp-session-id'];
31
- let transport;
32
- if (sessionId && transports[sessionId]) {
33
- transport = transports[sessionId];
34
- }
35
- else if (!sessionId && isInitializeRequest(req.body)) {
36
- transport = new StreamableHTTPServerTransport({
37
- sessionIdGenerator: () => crypto.randomUUID(),
38
- onsessioninitialized: (sessionId) => {
39
- transports[sessionId] = transport;
40
- },
41
- });
42
- transport.onclose = () => {
43
- if (transport.sessionId) {
44
- delete transports[transport.sessionId];
45
- }
46
- };
47
- const roles = req.unchainedContext.user.roles || [];
48
- const server = initMCPServer(new McpServer({
49
- name: 'Unchained MCP Server',
50
- version: '1.0.0',
51
- }), req.unchainedContext, roles);
52
- await server.connect(transport);
53
- }
54
- else {
55
- return res.status(400).send(JSON.stringify({
56
- jsonrpc: '2.0',
57
- error: {
58
- code: -32000,
59
- message: 'Bad Request: No valid session ID provided',
60
- },
61
- id: null,
62
- }));
63
- }
64
- await transport.handleRequest(req.raw, res.raw, req.body);
65
- return res;
66
- }
67
- else if (req.method === 'GET' || req.method === 'DELETE') {
68
- const sessionId = req.headers['mcp-session-id'];
69
- if (!sessionId || !transports[sessionId]) {
70
- return res.status(400).send('Invalid or missing session ID');
71
- }
72
- const transport = transports[sessionId];
73
- await transport.handleRequest(req.raw, res.raw);
74
- return res;
75
- }
76
- return res.status(405).send('Method Not Allowed');
8
+ const bodyText = req.method === 'POST' && req.body !== undefined ? JSON.stringify(req.body) : undefined;
9
+ const response = await handleMcpHttpRequest(req.unchainedContext, toWebRequest(req.raw, res.raw, bodyText), req.method === 'POST' ? req.body : undefined);
10
+ res.status(response.status);
11
+ response.headers.forEach((value, key) => {
12
+ if (key === 'set-cookie')
13
+ return;
14
+ res.header(key, value);
15
+ });
16
+ const setCookie = response.headers.getSetCookie();
17
+ if (setCookie.length)
18
+ res.header('set-cookie', setCookie);
19
+ if (!response.body)
20
+ return res.send();
21
+ return res.send(Readable.fromWeb(response.body));
77
22
  }
78
- catch (e) {
79
- logger.error(e);
80
- return res.status(503).send({ name: e.name, code: e.code, message: e.message });
23
+ catch (error) {
24
+ logger.error(error);
25
+ if (!res.sent) {
26
+ return res.status(500).send({ error: 'Internal Server Error' });
27
+ }
28
+ res.raw.destroy();
29
+ return res;
81
30
  }
82
31
  };
83
32
  export default mcpHandler;
@@ -0,0 +1,13 @@
1
+ import type { UnchainedCore } from '@unchainedshop/core';
2
+ import type { Order } from '@unchainedshop/core-orders';
3
+ import DataLoader from 'dataloader';
4
+ declare const _default: (unchainedAPI: UnchainedCore) => DataLoader<{
5
+ userId: string;
6
+ countryCode?: string;
7
+ orderNumber?: string;
8
+ }, Order | null, {
9
+ userId: string;
10
+ countryCode?: string;
11
+ orderNumber?: string;
12
+ }>;
13
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import DataLoader from 'dataloader';
2
+ export default (unchainedAPI) => new DataLoader(async (queries) => {
3
+ const userIds = [...new Set(queries.map((q) => q.userId).filter(Boolean))];
4
+ const carts = await unchainedAPI.modules.orders.findCarts({ userIds });
5
+ return queries.map((q) => carts.find((cart) => cart.userId === q.userId &&
6
+ (!q.countryCode || cart.countryCode === q.countryCode) &&
7
+ (!q.orderNumber || cart.orderNumber === q.orderNumber)) || null);
8
+ });
@@ -166,6 +166,15 @@ declare const loaders: (unchainedAPI: UnchainedCore) => {
166
166
  }, import("@unchainedshop/core-orders").Order, {
167
167
  orderId: string;
168
168
  }>;
169
+ cartLoader: import("dataloader")<{
170
+ userId: string;
171
+ countryCode?: string;
172
+ orderNumber?: string;
173
+ }, import("@unchainedshop/core-orders").Order | null, {
174
+ userId: string;
175
+ countryCode?: string;
176
+ orderNumber?: string;
177
+ }>;
169
178
  quotationLoader: import("dataloader")<{
170
179
  quotationId: string;
171
180
  }, import("@unchainedshop/core-quotations").Quotation | null, {
@@ -26,6 +26,7 @@ import deliveryProviderLoader from "./deliveryProviderLoader.js";
26
26
  import paymentProviderLoader from "./paymentProviderLoader.js";
27
27
  import warehousingProviderLoader from "./warehousingProviderLoader.js";
28
28
  import orderLoader from "./orderLoader.js";
29
+ import cartLoader from "./cartLoader.js";
29
30
  import quotationLoader from "./quotationLoader.js";
30
31
  import tokenExportStatusLoader from "./tokenExportStatusLoader.js";
31
32
  const loaders = (unchainedAPI) => {
@@ -58,6 +59,7 @@ const loaders = (unchainedAPI) => {
58
59
  paymentProviderLoader: paymentProviderLoader(unchainedAPI),
59
60
  warehousingProviderLoader: warehousingProviderLoader(unchainedAPI),
60
61
  orderLoader: orderLoader(unchainedAPI),
62
+ cartLoader: cartLoader(unchainedAPI),
61
63
  quotationLoader: quotationLoader(unchainedAPI),
62
64
  tokenExportStatusLoader: tokenExportStatusLoader(unchainedAPI),
63
65
  };
@@ -6,12 +6,10 @@ export interface UnchainedLocaleContext {
6
6
  currencyCode: string;
7
7
  }
8
8
  export type GetHeaderFn = (key: string) => string | string[];
9
- export declare const resolveDefaultContext: (({ acceptLang, acceptCountry }: {
9
+ export declare const resolveDefaultContext: import("@unchainedshop/utils/lib/memoize-with-ttl.js").MemoizedWithTTL<[{
10
10
  acceptLang: any;
11
11
  acceptCountry: any;
12
- }, unchainedAPI: UnchainedCore) => Promise<UnchainedLocaleContext>) & {
13
- clear: () => void;
14
- };
12
+ }, unchainedAPI: UnchainedCore], UnchainedLocaleContext>;
15
13
  export declare const getLocaleContext: ({ getHeader, }: {
16
14
  getHeader: UnchainedHTTPServerContext["getHeader"];
17
15
  }, unchainedAPI: UnchainedCore) => Promise<UnchainedLocaleContext>;
@@ -16,7 +16,8 @@ const uncachedResolveDefaultContext = async ({ acceptLang, acceptCountry }, unch
16
16
  };
17
17
  return newContext;
18
18
  };
19
- export const resolveDefaultContext = memoizeWithTTL(uncachedResolveDefaultContext, process.env.NODE_ENV === 'production' ? 1000 * 60 : 1, {
19
+ export const resolveDefaultContext = memoizeWithTTL(uncachedResolveDefaultContext, {
20
+ ttl: process.env.NODE_ENV === 'production' ? 1000 * 60 : 1,
20
21
  cacheKey: (args) => {
21
22
  const [{ acceptLang, acceptCountry }] = args;
22
23
  return `${acceptLang}-${acceptCountry}`;
@@ -0,0 +1,2 @@
1
+ import type { Context } from '../context.ts';
2
+ export default function handleMcpHttpRequest(context: Context | undefined, request: Request, parsedBody?: unknown): Promise<Response>;
@@ -0,0 +1,83 @@
1
+ import { createLogger } from '@unchainedshop/logger';
2
+ import createMcpServer from "./index.js";
3
+ import { isPeerNotInstalledError } from "../utils/optionalPeerError.js";
4
+ const logger = createLogger('unchained:api:mcp');
5
+ let createMcpHandler;
6
+ let McpServer;
7
+ let hostHeaderValidationResponse;
8
+ let originValidationResponse;
9
+ let localhostAllowedHostnames;
10
+ let localhostAllowedOrigins;
11
+ try {
12
+ const mcpSDKServer = await import('@modelcontextprotocol/server');
13
+ createMcpHandler = mcpSDKServer.createMcpHandler;
14
+ McpServer = mcpSDKServer.McpServer;
15
+ hostHeaderValidationResponse = mcpSDKServer.hostHeaderValidationResponse;
16
+ originValidationResponse = mcpSDKServer.originValidationResponse;
17
+ localhostAllowedHostnames = mcpSDKServer.localhostAllowedHostnames;
18
+ localhostAllowedOrigins = mcpSDKServer.localhostAllowedOrigins;
19
+ }
20
+ catch (error) {
21
+ if (isPeerNotInstalledError('@modelcontextprotocol/server', error)) {
22
+ logger.warn(`optional peer npm package '@modelcontextprotocol/server' not installed, mcp will not work`);
23
+ }
24
+ else {
25
+ logger.error(`failed to load '@modelcontextprotocol/server'`, error);
26
+ }
27
+ }
28
+ let handler;
29
+ const getHandler = () => {
30
+ handler ??= createMcpHandler(async ({ authInfo }) => {
31
+ const { context, roles } = authInfo.extra;
32
+ return createMcpServer(new McpServer({ name: 'Unchained MCP Server', version: '1.0.0' }), context, roles);
33
+ });
34
+ return handler;
35
+ };
36
+ const resourceUrl = () => process.env.ROOT_URL || 'http://localhost:4010';
37
+ const configuredHostname = () => {
38
+ try {
39
+ return new URL(resourceUrl()).hostname;
40
+ }
41
+ catch {
42
+ return 'localhost';
43
+ }
44
+ };
45
+ const allowedHostnames = () => [...new Set([...localhostAllowedHostnames(), configuredHostname()])];
46
+ const allowedOrigins = () => [...new Set([...localhostAllowedOrigins(), configuredHostname()])];
47
+ export default async function handleMcpHttpRequest(context, request, parsedBody) {
48
+ const user = context?.user;
49
+ if (!user) {
50
+ return Response.json({
51
+ error: 'invalid_token',
52
+ resource_metadata: `${resourceUrl()}/.well-known/oauth-protected-resource`,
53
+ }, {
54
+ status: 401,
55
+ headers: {
56
+ 'WWW-Authenticate': `Bearer realm="Unchained MCP", error="invalid_token", resource="${resourceUrl()}",`,
57
+ },
58
+ });
59
+ }
60
+ const roles = user.roles || [];
61
+ if (!roles.includes('admin')) {
62
+ return Response.json({ error: 'forbidden', message: 'MCP requires admin privileges' }, { status: 403 });
63
+ }
64
+ if (!createMcpHandler) {
65
+ return Response.json({
66
+ error: 'unavailable',
67
+ message: `MCP is enabled but the optional peer npm package '@modelcontextprotocol/server' is not installed`,
68
+ }, { status: 503 });
69
+ }
70
+ const validationFailure = hostHeaderValidationResponse(request, allowedHostnames()) ??
71
+ originValidationResponse(request, allowedOrigins());
72
+ if (validationFailure)
73
+ return validationFailure;
74
+ return getHandler().fetch(request, {
75
+ authInfo: {
76
+ token: 'unchained-context',
77
+ clientId: user._id,
78
+ scopes: roles,
79
+ extra: { context, roles },
80
+ },
81
+ parsedBody,
82
+ });
83
+ }
@@ -1,3 +1,3 @@
1
- import type { McpServer as McpServerType } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import type { McpServer as McpServerType } from '@modelcontextprotocol/server';
2
2
  import type { Context } from '../context.ts';
3
3
  export default function createMcpServer(server: McpServerType, context: Context, roles: any): McpServerType;
@@ -0,0 +1,3 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export declare function toWebRequest(req: IncomingMessage, res: ServerResponse, bodyText?: string): Request;
3
+ export declare function sendWebResponse(res: ServerResponse, response: Response): Promise<void>;
@@ -0,0 +1,64 @@
1
+ import { Readable, pipeline } from 'node:stream';
2
+ export function toWebRequest(req, res, bodyText) {
3
+ const controller = new AbortController();
4
+ res.once('close', () => {
5
+ if (!res.writableFinished)
6
+ controller.abort();
7
+ });
8
+ const headers = new Headers();
9
+ for (const [key, value] of Object.entries(req.headers)) {
10
+ if (Array.isArray(value)) {
11
+ for (const entry of value)
12
+ headers.append(key, entry);
13
+ }
14
+ else if (value != null) {
15
+ headers.set(key, value);
16
+ }
17
+ }
18
+ if (bodyText !== undefined)
19
+ headers.delete('content-length');
20
+ let url;
21
+ try {
22
+ url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
23
+ }
24
+ catch {
25
+ try {
26
+ url = new URL(req.url || '/', 'http://localhost');
27
+ }
28
+ catch {
29
+ url = new URL('http://localhost/');
30
+ }
31
+ }
32
+ return new Request(url, {
33
+ method: req.method,
34
+ headers,
35
+ body: bodyText,
36
+ signal: controller.signal,
37
+ });
38
+ }
39
+ export async function sendWebResponse(res, response) {
40
+ if (res.destroyed || res.writableEnded) {
41
+ await response.body?.cancel().catch(() => undefined);
42
+ return;
43
+ }
44
+ const headers = {};
45
+ response.headers.forEach((value, key) => {
46
+ headers[key] = key === 'set-cookie' ? response.headers.getSetCookie() : value;
47
+ });
48
+ res.writeHead(response.status, headers);
49
+ if (!response.body) {
50
+ res.end();
51
+ return;
52
+ }
53
+ const nodeStream = Readable.fromWeb(response.body);
54
+ await new Promise((resolve, reject) => {
55
+ pipeline(nodeStream, res, (error) => {
56
+ if (!error || error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
57
+ resolve();
58
+ }
59
+ else {
60
+ reject(error);
61
+ }
62
+ });
63
+ });
64
+ }