@unchainedshop/api 4.8.20 → 4.8.22
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 +6 -4
- package/lib/chat/utils.d.ts +8 -0
- package/lib/chat/utils.js +54 -2
- package/lib/express/chatHandler.js +52 -61
- package/lib/express/createMCPMiddleware.js +18 -99
- package/lib/fastify/chatHandler.js +48 -55
- package/lib/fastify/mcpHandler.js +24 -94
- package/lib/locale-context.d.ts +2 -2
- package/lib/locale-context.js +3 -6
- 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/index.d.ts +1 -1
- package/lib/mcp/tools/assortment/index.js +4 -1
- package/lib/mcp/tools/assortment/schemas.d.ts +1 -3
- 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 +1 -3
- 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 +1 -3
- 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 +1 -3
- 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 +1 -3
- 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 +1 -3
- 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 +1 -3
- package/lib/mcp/tools/system/index.d.ts +1 -1
- package/lib/mcp/tools/system/index.js +4 -1
- package/lib/mcp/tools/system/schemas.d.ts +1 -3
- 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 +1 -3
- package/lib/mcp/utils/sharedSchemas.d.ts +3 -5
- package/lib/mcp/utils/sharedSchemas.js +18 -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 +1 -1
- package/lib/schema/types/filter.js +2 -2
- package/lib/utils/optionalPeerError.d.ts +1 -0
- package/lib/utils/optionalPeerError.js +6 -0
- package/package.json +32 -8
package/README.md
CHANGED
|
@@ -141,14 +141,16 @@ The API exposes a complete GraphQL schema with:
|
|
|
141
141
|
|
|
142
142
|
## MCP Server
|
|
143
143
|
|
|
144
|
-
Model Context Protocol server for AI agent integrations
|
|
144
|
+
Model Context Protocol server for AI agent integrations. `connect()` (Express or Fastify) automatically mounts a stateless MCP endpoint at `/mcp` (configurable via `MCP_API_PATH`). The endpoint requires an authenticated user with the `admin` role and serves 9 management tools plus the shop localization resources.
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
import { createMCPServer } from '@unchainedshop/api/mcp';
|
|
146
|
+
MCP support is an optional peer dependency:
|
|
148
147
|
|
|
149
|
-
|
|
148
|
+
```bash
|
|
149
|
+
npm install @modelcontextprotocol/server
|
|
150
150
|
```
|
|
151
151
|
|
|
152
|
+
Without it, the engine boots normally and `/mcp` responds with `503`. The chat handlers (`connect(..., { chat })`) additionally require the optional peers `ai` and `@ai-sdk/mcp` — and `@modelcontextprotocol/server` too, since chat derives its tool set through the engine's own `/mcp` endpoint.
|
|
153
|
+
|
|
152
154
|
## Security
|
|
153
155
|
|
|
154
156
|
The API layer implements comprehensive security controls.
|
package/lib/chat/utils.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type * as aiTypes from 'ai';
|
|
2
|
+
import type { ServerResponse } from 'node:http';
|
|
3
|
+
export declare const logOptionalPeerLoadError: (packageName: string, error: unknown) => void;
|
|
2
4
|
export type StreamTextParams = Parameters<typeof aiTypes.streamText>[0];
|
|
3
5
|
export type ChatConfiguration = Omit<StreamTextParams, 'messages'> & {
|
|
4
6
|
unchainedMCPUrl?: string;
|
|
@@ -8,4 +10,10 @@ export type ChatConfiguration = Omit<StreamTextParams, 'messages'> & {
|
|
|
8
10
|
};
|
|
9
11
|
};
|
|
10
12
|
export declare const errorHandler: (error: any) => string;
|
|
13
|
+
export declare const chatErrorStatus: (error: unknown) => number;
|
|
14
|
+
export declare const createChatRequestLifecycle: (response: ServerResponse, configuredSignal?: AbortSignal) => {
|
|
15
|
+
signal: AbortSignal;
|
|
16
|
+
setClientClose(closeClient: () => Promise<void>): void;
|
|
17
|
+
close: () => Promise<void>;
|
|
18
|
+
};
|
|
11
19
|
export declare const categorizeTools: (toolName: string) => string;
|
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,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,22 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
43
43
|
res.status(405).json({ error: 'Method Not Allowed. Use POST.' });
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
headers: {
|
|
49
|
-
Cookie: req.headers.cookie || '',
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
const sdkClient = new Client({ name: 'unchained-chat-client', version: '1.0.0' });
|
|
54
|
-
await sdkClient.connect(resourceTransport);
|
|
55
|
-
const transport = new StreamableHTTPClientTransport(new URL(unchainedMCPUrl), {
|
|
56
|
-
requestInit: {
|
|
57
|
-
headers: {
|
|
58
|
-
Cookie: req.headers.cookie || '',
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
const client = await createMCPClient({
|
|
63
|
-
transport,
|
|
64
|
-
});
|
|
46
|
+
let client;
|
|
47
|
+
const lifecycle = createChatRequestLifecycle(res, configuredAbortSignal);
|
|
65
48
|
try {
|
|
49
|
+
client = await createMCPClient({
|
|
50
|
+
transport: {
|
|
51
|
+
type: 'http',
|
|
52
|
+
url: unchainedMCPUrl,
|
|
53
|
+
headers: {
|
|
54
|
+
Cookie: req.headers.cookie || '',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
initializationOptions: { signal: lifecycle.signal },
|
|
58
|
+
});
|
|
59
|
+
lifecycle.setClientClose(() => client.close());
|
|
66
60
|
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
|
-
}
|
|
61
|
+
const resourceContext = await buildChatResourceContext(req.unchainedContext);
|
|
90
62
|
const tools = {
|
|
91
63
|
...defaultUnchainedTools,
|
|
92
64
|
...additionalTools,
|
|
@@ -95,6 +67,7 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
95
67
|
tools.generateImage = generateImageHandler(req)(imageGenerationTool);
|
|
96
68
|
}
|
|
97
69
|
if (req.method === 'GET') {
|
|
70
|
+
await lifecycle.close();
|
|
98
71
|
res.status(200).json({
|
|
99
72
|
tools: normalizeToolsIndex(tools),
|
|
100
73
|
cached: false,
|
|
@@ -130,15 +103,28 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
130
103
|
}
|
|
131
104
|
const messagesToInclude = normalizedMessages.slice(startIndex);
|
|
132
105
|
const result = streamText({
|
|
133
|
-
stopWhen: stepCountIs(
|
|
134
|
-
temperature: 0.2,
|
|
106
|
+
stopWhen: stepCountIs(500),
|
|
135
107
|
maxRetries: 3,
|
|
136
108
|
...restChatConfig,
|
|
109
|
+
abortSignal: lifecycle.signal,
|
|
137
110
|
system: system + resourceContext,
|
|
138
111
|
model,
|
|
139
112
|
tools: cacheControlledTools,
|
|
140
|
-
|
|
141
|
-
|
|
113
|
+
onEnd: async (event) => {
|
|
114
|
+
try {
|
|
115
|
+
await (configuredOnEnd ?? configuredOnFinish)?.(event);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await lifecycle.close();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
onAbort: async (event) => {
|
|
122
|
+
try {
|
|
123
|
+
await configuredOnAbort?.(event);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await lifecycle.close();
|
|
127
|
+
}
|
|
142
128
|
},
|
|
143
129
|
messages: messagesToInclude,
|
|
144
130
|
providerOptions: {
|
|
@@ -149,20 +135,25 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
149
135
|
},
|
|
150
136
|
},
|
|
151
137
|
});
|
|
138
|
+
void result.finishReason.then(undefined, async () => {
|
|
139
|
+
await lifecycle.close();
|
|
140
|
+
});
|
|
152
141
|
result.pipeUIMessageStreamToResponse(res, {
|
|
153
142
|
onError: errorHandler,
|
|
154
143
|
});
|
|
155
144
|
}
|
|
156
145
|
catch (err) {
|
|
157
|
-
|
|
158
|
-
await
|
|
159
|
-
|
|
146
|
+
logger.error(err);
|
|
147
|
+
await lifecycle.close();
|
|
148
|
+
if (lifecycle.signal.aborted || res.destroyed || res.writableEnded)
|
|
149
|
+
return;
|
|
150
|
+
res.status(chatErrorStatus(err)).json({ error: errorHandler(err) });
|
|
160
151
|
}
|
|
161
152
|
};
|
|
162
153
|
return mcpChatHandler;
|
|
163
154
|
};
|
|
164
155
|
export const connectChat = (app, chatConfiguration) => {
|
|
165
|
-
if (!createMCPClient) {
|
|
156
|
+
if (!createMCPClient || !streamText) {
|
|
166
157
|
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
158
|
return;
|
|
168
159
|
}
|
|
@@ -1,106 +1,25 @@
|
|
|
1
1
|
import { createLogger } from '@unchainedshop/logger';
|
|
2
|
+
import handleMcpHttpRequest from "../mcp/handleMcpHttpRequest.js";
|
|
3
|
+
import { toWebRequest, sendWebResponse } from "../mcp/nodeHttpBridge.js";
|
|
2
4
|
const logger = createLogger('unchained:api:mcp');
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const mcpSDKServerLibrary = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
8
|
-
const mcpSDKClient = await import('@modelcontextprotocol/sdk/types.js');
|
|
9
|
-
const mcpSDKServer = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
10
|
-
McpServer = mcpSDKServer.McpServer;
|
|
11
|
-
StreamableHTTPServerTransport = mcpSDKServerLibrary.StreamableHTTPServerTransport;
|
|
12
|
-
isInitializeRequest = mcpSDKClient.isInitializeRequest;
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
logger.warn(`optional peer npm package '@modelcontextprotocol/sdk' not installed, mcp will not work`);
|
|
16
|
-
}
|
|
17
|
-
const transports = {};
|
|
18
|
-
const handlePostRequest = async (req, res) => {
|
|
19
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
20
|
-
const currentUserId = req.unchainedContext.user._id;
|
|
21
|
-
let transport;
|
|
22
|
-
if (sessionId && transports[sessionId]) {
|
|
23
|
-
if (transports[sessionId].userId !== currentUserId) {
|
|
24
|
-
res.status(404).json({
|
|
25
|
-
jsonrpc: '2.0',
|
|
26
|
-
error: {
|
|
27
|
-
code: -32000,
|
|
28
|
-
message: 'Bad Request: No valid session ID provided',
|
|
29
|
-
},
|
|
30
|
-
id: null,
|
|
31
|
-
});
|
|
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');
|
|
32
9
|
return;
|
|
33
10
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (transport.sessionId) {
|
|
47
|
-
delete transports[transport.sessionId];
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
const roles = contextHolder.user?.roles || [];
|
|
51
|
-
const { default: initMCPServer } = await import("../mcp/index.js");
|
|
52
|
-
const server = initMCPServer(new McpServer({
|
|
53
|
-
name: 'Unchained MCP Server',
|
|
54
|
-
version: '1.0.0',
|
|
55
|
-
}), contextHolder, roles);
|
|
56
|
-
await server.connect(transport);
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
res.status(400).json({
|
|
60
|
-
jsonrpc: '2.0',
|
|
61
|
-
error: {
|
|
62
|
-
code: -32000,
|
|
63
|
-
message: 'Bad Request: No valid session ID provided',
|
|
64
|
-
},
|
|
65
|
-
id: null,
|
|
66
|
-
});
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
await transport.handleRequest(req, res, req.body);
|
|
70
|
-
};
|
|
71
|
-
const handleSessionRequest = async (req, res) => {
|
|
72
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
73
|
-
if (!sessionId ||
|
|
74
|
-
!transports[sessionId] ||
|
|
75
|
-
transports[sessionId].userId !== req.unchainedContext.user._id) {
|
|
76
|
-
res.status(400).send('Invalid or missing session ID');
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
Object.assign(transports[sessionId].context, req.unchainedContext);
|
|
80
|
-
const transport = transports[sessionId].transport;
|
|
81
|
-
await transport.handleRequest(req, res);
|
|
82
|
-
};
|
|
83
|
-
const createMCPMiddleware = (req, res, next) => {
|
|
84
|
-
const user = req.unchainedContext.user;
|
|
85
|
-
if (!user) {
|
|
86
|
-
res.status(401);
|
|
87
|
-
res.header('WWW-Authenticate', `Bearer realm="Unchained MCP", error="invalid_token", resource="${process.env.ROOT_URL || 'http://localhost:4010'}",`);
|
|
88
|
-
res.json({
|
|
89
|
-
error: 'invalid_token',
|
|
90
|
-
resource_metadata: `${process.env.ROOT_URL || 'http://localhost:4010'}/.well-known/oauth-protected-resource`,
|
|
91
|
-
});
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (!(user.roles || []).includes('admin')) {
|
|
95
|
-
res.status(403).json({ error: 'forbidden', message: 'MCP requires admin privileges' });
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
if (req.method === 'POST') {
|
|
99
|
-
return handlePostRequest(req, res, next);
|
|
100
|
-
}
|
|
101
|
-
else if (req.method === 'GET' || req.method === 'DELETE') {
|
|
102
|
-
return handleSessionRequest(req, res, next);
|
|
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);
|
|
14
|
+
}
|
|
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
|
+
}
|
|
103
23
|
}
|
|
104
|
-
res.status(405).send('Method Not Allowed');
|
|
105
24
|
};
|
|
106
25
|
export default createMCPMiddleware;
|
|
@@ -1,55 +1,47 @@
|
|
|
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
|
-
|
|
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
45
|
client = await createMCPClient({
|
|
54
46
|
transport: {
|
|
55
47
|
type: 'http',
|
|
@@ -58,31 +50,11 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
58
50
|
Cookie: req.headers.cookie || '',
|
|
59
51
|
},
|
|
60
52
|
},
|
|
53
|
+
initializationOptions: { signal: lifecycle.signal },
|
|
61
54
|
});
|
|
55
|
+
lifecycle.setClientClose(() => client.close());
|
|
62
56
|
const defaultUnchainedTools = await client.tools();
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const resources = await sdkClient.listResources();
|
|
66
|
-
if (resources?.resources) {
|
|
67
|
-
const resourceTexts = await Promise.all(resources.resources.map(async (resource) => {
|
|
68
|
-
try {
|
|
69
|
-
const content = await sdkClient.readResource({ uri: resource.uri });
|
|
70
|
-
if (content?.contents?.[0]?.text) {
|
|
71
|
-
return `${resource.name}:\n${content.contents[0].text}`;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
catch (e) {
|
|
75
|
-
logger.error(`Failed to read resource ${resource.uri}: ${e.message}`);
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
78
|
-
}));
|
|
79
|
-
resourceContext =
|
|
80
|
-
'\n\nAVAILABLE SHOP CONFIGURATION:\n' + resourceTexts.filter(Boolean).join('\n\n');
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
catch (e) {
|
|
84
|
-
logger.error(`Failed to fetch MCP resources: ${e.message}`);
|
|
85
|
-
}
|
|
57
|
+
const resourceContext = await buildChatResourceContext(req.unchainedContext);
|
|
86
58
|
const tools = {
|
|
87
59
|
...defaultUnchainedTools,
|
|
88
60
|
...additionalTools,
|
|
@@ -91,6 +63,7 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
91
63
|
tools.generateImage = generateImageHandler(req)(imageGenerationTool);
|
|
92
64
|
}
|
|
93
65
|
if (req.method === 'GET') {
|
|
66
|
+
await lifecycle.close();
|
|
94
67
|
return res.status(200).send({
|
|
95
68
|
tools: normalizeToolsIndex(tools),
|
|
96
69
|
cached: false,
|
|
@@ -128,11 +101,26 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
128
101
|
stopWhen: stepCountIs(500),
|
|
129
102
|
maxRetries: 3,
|
|
130
103
|
...restChatConfig,
|
|
104
|
+
abortSignal: lifecycle.signal,
|
|
131
105
|
messages: messagesToInclude,
|
|
132
106
|
system: system + resourceContext,
|
|
107
|
+
model,
|
|
133
108
|
tools: cacheControlledTools,
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
|
136
124
|
},
|
|
137
125
|
providerOptions: {
|
|
138
126
|
anthropic: {
|
|
@@ -142,21 +130,26 @@ const setupMCPChatHandler = (chatConfiguration) => {
|
|
|
142
130
|
},
|
|
143
131
|
},
|
|
144
132
|
});
|
|
133
|
+
void result.finishReason.then(undefined, async () => {
|
|
134
|
+
await lifecycle.close();
|
|
135
|
+
});
|
|
145
136
|
return res.send(result.toUIMessageStreamResponse({
|
|
146
137
|
onError: errorHandler,
|
|
147
138
|
}));
|
|
148
139
|
}
|
|
149
140
|
catch (err) {
|
|
150
141
|
logger.error(err);
|
|
151
|
-
await
|
|
152
|
-
res.
|
|
142
|
+
await lifecycle.close();
|
|
143
|
+
if (lifecycle.signal.aborted || res.raw.destroyed)
|
|
144
|
+
return res;
|
|
145
|
+
res.status(chatErrorStatus(err));
|
|
153
146
|
return res.send({ error: errorHandler(err) });
|
|
154
147
|
}
|
|
155
148
|
};
|
|
156
149
|
return mcpChatHandler;
|
|
157
150
|
};
|
|
158
151
|
export const connectChat = (app, chatConfiguration) => {
|
|
159
|
-
if (!createMCPClient) {
|
|
152
|
+
if (!createMCPClient || !streamText) {
|
|
160
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.');
|
|
161
154
|
return;
|
|
162
155
|
}
|