@wukongcrm/mcp-server 0.1.3 → 0.2.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/README.md +969 -97
- package/dist/fc-entry.d.ts +1 -0
- package/dist/fc-entry.js +7 -0
- package/dist/http-entry.d.ts +1 -0
- package/dist/http-entry.js +51 -0
- package/dist/http.d.ts +47 -0
- package/dist/http.js +550 -0
- package/dist/modules.js +4 -4
- package/dist/nocode.d.ts +16 -0
- package/dist/nocode.js +1086 -0
- package/dist/oauth-state.d.ts +94 -0
- package/dist/oauth-state.js +310 -0
- package/dist/oauth.d.ts +59 -0
- package/dist/oauth.js +446 -0
- package/dist/server.d.ts +12 -1
- package/dist/server.js +240 -19
- package/dist/tools.js +3897 -540
- package/package.json +13 -15
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/fc-entry.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Aliyun Function Compute custom runtimes require the HTTP server to listen on
|
|
2
|
+
// 0.0.0.0 and use port 9000 by default. Explicit function environment values
|
|
3
|
+
// still take precedence when a different listener port is configured.
|
|
4
|
+
process.env.MCP_HOST ||= "0.0.0.0";
|
|
5
|
+
process.env.MCP_PORT ||= process.env.PORT || "9000";
|
|
6
|
+
await import("./http-entry.js");
|
|
7
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createWukongHttpApp, loadRemoteServerEnvironment } from "./http.js";
|
|
2
|
+
const environment = loadRemoteServerEnvironment();
|
|
3
|
+
const { app, provider, resourceUrl } = createWukongHttpApp({
|
|
4
|
+
publicUrl: environment.publicUrl,
|
|
5
|
+
oauthSecret: environment.oauthSecret,
|
|
6
|
+
dataDirectory: environment.dataDirectory,
|
|
7
|
+
redisUrl: environment.redisUrl,
|
|
8
|
+
redisKeyPrefix: environment.redisKeyPrefix,
|
|
9
|
+
crmBaseUrl: environment.crmBaseUrl,
|
|
10
|
+
crmBindingAuthorizeUrl: environment.crmBindingAuthorizeUrl,
|
|
11
|
+
crmBindingExchangeUrl: environment.crmBindingExchangeUrl,
|
|
12
|
+
crmBindingExchangeAllowHttp: environment.crmBindingExchangeAllowHttp,
|
|
13
|
+
crmBindingCallbackUrl: environment.crmBindingCallbackUrl,
|
|
14
|
+
crmBindingClientId: environment.crmBindingClientId,
|
|
15
|
+
crmBindingClientSecret: environment.crmBindingClientSecret,
|
|
16
|
+
host: environment.host,
|
|
17
|
+
allowedHosts: environment.allowedHosts,
|
|
18
|
+
trustProxy: environment.trustProxy
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
await provider.initialize();
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
console.error("Failed to initialize OAuth state store:", error instanceof Error ? error.message : "unknown error");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const httpServer = app.listen(environment.port, environment.host, () => {
|
|
28
|
+
console.log(`WukongCRM remote MCP listening on ${environment.host}:${environment.port}`);
|
|
29
|
+
console.log(`Public MCP URL: ${resourceUrl.href}`);
|
|
30
|
+
console.log(`Express trust proxy: ${String(environment.trustProxy)}`);
|
|
31
|
+
console.log(`OAuth state store: ${provider.clientsStore.kind === "redis" ? "Redis" : "JSON file"}`);
|
|
32
|
+
});
|
|
33
|
+
// Function Compute may keep a request or connection alive for much longer than
|
|
34
|
+
// Node.js defaults. The function-level execution timeout remains the hard limit.
|
|
35
|
+
httpServer.timeout = 0;
|
|
36
|
+
httpServer.keepAliveTimeout = 0;
|
|
37
|
+
httpServer.headersTimeout = 0;
|
|
38
|
+
httpServer.on("error", (error) => {
|
|
39
|
+
console.error("Failed to start remote MCP server:", error.message);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
});
|
|
42
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
43
|
+
process.on(signal, () => {
|
|
44
|
+
httpServer.close(async () => {
|
|
45
|
+
await provider.close().catch((error) => {
|
|
46
|
+
console.error("Failed to close OAuth state store:", error instanceof Error ? error.message : "unknown error");
|
|
47
|
+
});
|
|
48
|
+
process.exit(0);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Express } from "express";
|
|
2
|
+
import { WukongOAuthProvider } from "./oauth.js";
|
|
3
|
+
export type TrustProxySetting = boolean | number | string;
|
|
4
|
+
export interface WukongHttpAppOptions {
|
|
5
|
+
publicUrl: string | URL;
|
|
6
|
+
oauthSecret: string;
|
|
7
|
+
dataDirectory: string;
|
|
8
|
+
redisUrl?: string;
|
|
9
|
+
redisKeyPrefix?: string;
|
|
10
|
+
crmBaseUrl?: string;
|
|
11
|
+
crmBindingAuthorizeUrl: string | URL;
|
|
12
|
+
crmBindingExchangeUrl: string | URL;
|
|
13
|
+
crmBindingExchangeAllowHttp?: boolean;
|
|
14
|
+
crmBindingCallbackUrl?: string | URL;
|
|
15
|
+
crmBindingClientId: string;
|
|
16
|
+
crmBindingClientSecret: string;
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
host?: string;
|
|
19
|
+
allowedHosts?: string[];
|
|
20
|
+
trustProxy?: TrustProxySetting;
|
|
21
|
+
}
|
|
22
|
+
export interface WukongHttpApp {
|
|
23
|
+
app: Express;
|
|
24
|
+
provider: WukongOAuthProvider;
|
|
25
|
+
resourceUrl: URL;
|
|
26
|
+
resourceMetadataUrl: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function createWukongHttpApp(options: WukongHttpAppOptions): WukongHttpApp;
|
|
29
|
+
export interface RemoteServerEnvironment {
|
|
30
|
+
publicUrl: string;
|
|
31
|
+
oauthSecret: string;
|
|
32
|
+
dataDirectory: string;
|
|
33
|
+
redisUrl?: string;
|
|
34
|
+
redisKeyPrefix: string;
|
|
35
|
+
crmBaseUrl?: string;
|
|
36
|
+
crmBindingAuthorizeUrl: string;
|
|
37
|
+
crmBindingExchangeUrl: string;
|
|
38
|
+
crmBindingExchangeAllowHttp: boolean;
|
|
39
|
+
crmBindingCallbackUrl: string;
|
|
40
|
+
crmBindingClientId: string;
|
|
41
|
+
crmBindingClientSecret: string;
|
|
42
|
+
host: string;
|
|
43
|
+
allowedHosts: string[];
|
|
44
|
+
trustProxy: TrustProxySetting;
|
|
45
|
+
port: number;
|
|
46
|
+
}
|
|
47
|
+
export declare function loadRemoteServerEnvironment(env?: NodeJS.ProcessEnv): RemoteServerEnvironment;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { authorizationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
|
|
3
|
+
import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
|
|
4
|
+
import { clientRegistrationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/register.js";
|
|
5
|
+
import { revocationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/revoke.js";
|
|
6
|
+
import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/token.js";
|
|
7
|
+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
8
|
+
import { getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
9
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
10
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
11
|
+
import express from "express";
|
|
12
|
+
import { WukongOAuthProvider } from "./oauth.js";
|
|
13
|
+
import { MCP_RESOURCE_SCOPES, OAUTH_SCOPES, createWukongMcpServer } from "./server.js";
|
|
14
|
+
export function createWukongHttpApp(options) {
|
|
15
|
+
const resourceUrl = validatePublicUrl(options.publicUrl);
|
|
16
|
+
const issuerUrl = new URL("/", resourceUrl);
|
|
17
|
+
const resourcePath = resourceUrl.pathname;
|
|
18
|
+
const oauthBasePath = `${resourcePath}/oauth`;
|
|
19
|
+
const bindingCallbackUrl = options.crmBindingCallbackUrl
|
|
20
|
+
? new URL(options.crmBindingCallbackUrl.toString())
|
|
21
|
+
: new URL(`${oauthBasePath}/72crm/callback`, resourceUrl);
|
|
22
|
+
if (bindingCallbackUrl.origin !== resourceUrl.origin) {
|
|
23
|
+
throw new Error("CRM_BINDING_CALLBACK_URL 必须与 MCP_PUBLIC_URL 使用相同域名。");
|
|
24
|
+
}
|
|
25
|
+
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceUrl);
|
|
26
|
+
const resourceMetadataPath = new URL(resourceMetadataUrl).pathname;
|
|
27
|
+
const serviceDocumentationUrl = new URL("/", resourceUrl);
|
|
28
|
+
const host = options.host ?? "127.0.0.1";
|
|
29
|
+
const allowedHosts = options.allowedHosts ?? uniqueStrings([
|
|
30
|
+
resourceUrl.hostname,
|
|
31
|
+
"localhost",
|
|
32
|
+
"127.0.0.1",
|
|
33
|
+
"[::1]"
|
|
34
|
+
]);
|
|
35
|
+
const provider = new WukongOAuthProvider({
|
|
36
|
+
resourceUrl,
|
|
37
|
+
crmBaseUrl: options.crmBaseUrl,
|
|
38
|
+
crmBindingAuthorizeUrl: new URL(options.crmBindingAuthorizeUrl.toString()),
|
|
39
|
+
crmBindingExchangeUrl: new URL(options.crmBindingExchangeUrl.toString()),
|
|
40
|
+
crmBindingExchangeAllowHttp: options.crmBindingExchangeAllowHttp,
|
|
41
|
+
crmBindingCallbackUrl: bindingCallbackUrl,
|
|
42
|
+
crmBindingClientId: options.crmBindingClientId,
|
|
43
|
+
crmBindingClientSecret: options.crmBindingClientSecret,
|
|
44
|
+
secret: options.oauthSecret,
|
|
45
|
+
dataDirectory: options.dataDirectory,
|
|
46
|
+
redisUrl: options.redisUrl,
|
|
47
|
+
redisKeyPrefix: options.redisKeyPrefix,
|
|
48
|
+
fetchImpl: options.fetchImpl
|
|
49
|
+
});
|
|
50
|
+
const app = createMcpExpressApp({ host, allowedHosts });
|
|
51
|
+
app.set("trust proxy", options.trustProxy ?? false);
|
|
52
|
+
const protectedResourceMetadata = {
|
|
53
|
+
resource: resourceUrl.href,
|
|
54
|
+
authorization_servers: [issuerUrl.href],
|
|
55
|
+
scopes_supported: [...MCP_RESOURCE_SCOPES],
|
|
56
|
+
resource_name: "WukongCRM MCP",
|
|
57
|
+
resource_documentation: serviceDocumentationUrl.href
|
|
58
|
+
};
|
|
59
|
+
const oauthMetadata = {
|
|
60
|
+
issuer: issuerUrl.href,
|
|
61
|
+
service_documentation: serviceDocumentationUrl.href,
|
|
62
|
+
authorization_endpoint: new URL(`${oauthBasePath}/authorize`, resourceUrl).href,
|
|
63
|
+
token_endpoint: new URL(`${oauthBasePath}/token`, resourceUrl).href,
|
|
64
|
+
registration_endpoint: new URL(`${oauthBasePath}/register`, resourceUrl).href,
|
|
65
|
+
revocation_endpoint: new URL(`${oauthBasePath}/revoke`, resourceUrl).href,
|
|
66
|
+
response_types_supported: ["code"],
|
|
67
|
+
code_challenge_methods_supported: ["S256"],
|
|
68
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
69
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
70
|
+
scopes_supported: [...OAUTH_SCOPES],
|
|
71
|
+
revocation_endpoint_auth_methods_supported: ["none"]
|
|
72
|
+
};
|
|
73
|
+
app.use(express.urlencoded({ extended: false, limit: "16kb" }));
|
|
74
|
+
app.get(`${resourcePath}/healthz`, (_req, res) => {
|
|
75
|
+
res.status(200).json({ ok: true, service: "wukong-mcp" });
|
|
76
|
+
});
|
|
77
|
+
app.get(bindingCallbackUrl.pathname, async (req, res) => {
|
|
78
|
+
setSensitiveResponseHeaders(res);
|
|
79
|
+
try {
|
|
80
|
+
const requestId = typeof req.query.state === "string" ? req.query.state : "";
|
|
81
|
+
const bindingCode = typeof req.query.code === "string" ? req.query.code : "";
|
|
82
|
+
const redirectUrl = await provider.completeBindingAuthorization(requestId, bindingCode);
|
|
83
|
+
res.redirect(302, redirectUrl);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.warn("72CRM binding callback failed:", error instanceof Error ? error.message : "unknown error");
|
|
87
|
+
res.status(400).type("text").send("授权失败,请返回 ChatGPT 重新发起连接。");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
app.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata));
|
|
91
|
+
app.use(resourceMetadataPath, metadataHandler(protectedResourceMetadata));
|
|
92
|
+
app.use("/.well-known/oauth-authorization-server", metadataHandler(oauthMetadata));
|
|
93
|
+
app.use("/.well-known/openid-configuration", metadataHandler(oauthMetadata));
|
|
94
|
+
app.use(`${oauthBasePath}/authorize`, authorizationHandler({ provider }));
|
|
95
|
+
app.use(`${oauthBasePath}/token`, tokenHandler({ provider }));
|
|
96
|
+
app.use(`${oauthBasePath}/register`, clientRegistrationHandler({ clientsStore: provider.clientsStore }));
|
|
97
|
+
app.use(`${oauthBasePath}/revoke`, revocationHandler({ provider }));
|
|
98
|
+
const bearerAuth = requireBearerAuth({
|
|
99
|
+
verifier: provider,
|
|
100
|
+
requiredScopes: [...MCP_RESOURCE_SCOPES],
|
|
101
|
+
resourceMetadataUrl
|
|
102
|
+
});
|
|
103
|
+
app.post(resourceUrl.pathname, bearerAuth, async (req, res) => {
|
|
104
|
+
await handleMcpPost(req, res, options, resourceMetadataUrl);
|
|
105
|
+
});
|
|
106
|
+
app.get(resourceUrl.pathname, (req, res, next) => {
|
|
107
|
+
if (!req.accepts("html")) {
|
|
108
|
+
next();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
sendMcpLandingPage(res, resourceUrl, resourceMetadataUrl);
|
|
112
|
+
}, bearerAuth, (_req, res) => {
|
|
113
|
+
sendMethodNotAllowed(res);
|
|
114
|
+
});
|
|
115
|
+
app.delete(resourceUrl.pathname, bearerAuth, (_req, res) => {
|
|
116
|
+
sendMethodNotAllowed(res);
|
|
117
|
+
});
|
|
118
|
+
return { app, provider, resourceUrl, resourceMetadataUrl };
|
|
119
|
+
}
|
|
120
|
+
async function handleMcpPost(req, res, options, resourceMetadataUrl) {
|
|
121
|
+
const apiKey = req.auth?.extra?.crmApiKey;
|
|
122
|
+
if (typeof apiKey !== "string" || !apiKey) {
|
|
123
|
+
res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${resourceMetadataUrl}"`);
|
|
124
|
+
res.status(401).json({
|
|
125
|
+
jsonrpc: "2.0",
|
|
126
|
+
error: { code: -32001, message: "Unauthorized" },
|
|
127
|
+
id: null
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const server = createWukongMcpServer({
|
|
132
|
+
baseUrl: options.crmBaseUrl,
|
|
133
|
+
apiKey,
|
|
134
|
+
fetchImpl: options.fetchImpl,
|
|
135
|
+
oauth: {
|
|
136
|
+
scopes: req.auth?.scopes ?? [],
|
|
137
|
+
resourceMetadataUrl
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
141
|
+
try {
|
|
142
|
+
await server.connect(transport);
|
|
143
|
+
await transport.handleRequest(req, res, req.body);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error("MCP request failed:", error instanceof Error ? error.message : "unknown error");
|
|
147
|
+
if (!res.headersSent) {
|
|
148
|
+
res.status(500).json({
|
|
149
|
+
jsonrpc: "2.0",
|
|
150
|
+
error: { code: -32603, message: "Internal server error" },
|
|
151
|
+
id: null
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await transport.close().catch(() => undefined);
|
|
157
|
+
await server.close().catch(() => undefined);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function validatePublicUrl(value) {
|
|
161
|
+
const url = new URL(value.toString());
|
|
162
|
+
if (url.search || url.hash) {
|
|
163
|
+
throw new Error("MCP_PUBLIC_URL 不能包含查询参数或片段。");
|
|
164
|
+
}
|
|
165
|
+
const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
166
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) {
|
|
167
|
+
throw new Error("MCP_PUBLIC_URL 必须使用 HTTPS;只有本地开发地址可以使用 HTTP。");
|
|
168
|
+
}
|
|
169
|
+
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
|
170
|
+
if (!normalizedPath) {
|
|
171
|
+
throw new Error("MCP_PUBLIC_URL 必须包含 MCP 路径,例如 https://mcp.example.com/mcp。");
|
|
172
|
+
}
|
|
173
|
+
url.pathname = normalizedPath;
|
|
174
|
+
url.hash = "";
|
|
175
|
+
return url;
|
|
176
|
+
}
|
|
177
|
+
function setSensitiveResponseHeaders(res) {
|
|
178
|
+
res.setHeader("Cache-Control", "no-store");
|
|
179
|
+
res.setHeader("Pragma", "no-cache");
|
|
180
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
181
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
182
|
+
res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");
|
|
183
|
+
}
|
|
184
|
+
function sendMcpLandingPage(res, resourceUrl, resourceMetadataUrl) {
|
|
185
|
+
const endpoint = escapeHtml(resourceUrl.href);
|
|
186
|
+
const healthUrl = escapeHtml(new URL(`${resourceUrl.pathname}/healthz`, resourceUrl).href);
|
|
187
|
+
const metadataUrl = escapeHtml(resourceMetadataUrl);
|
|
188
|
+
const authorizationMetadataUrl = escapeHtml(new URL("/.well-known/oauth-authorization-server", resourceUrl).href);
|
|
189
|
+
setSensitiveResponseHeaders(res);
|
|
190
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
191
|
+
res.status(200).type("html").send(`<!doctype html>
|
|
192
|
+
<html lang="zh-CN">
|
|
193
|
+
<head>
|
|
194
|
+
<meta charset="utf-8">
|
|
195
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
196
|
+
<meta name="color-scheme" content="light">
|
|
197
|
+
<title>WukongCRM MCP · 服务说明</title>
|
|
198
|
+
<style>
|
|
199
|
+
:root {
|
|
200
|
+
--ink: #16332b;
|
|
201
|
+
--muted: #60736d;
|
|
202
|
+
--paper: #f6f4ec;
|
|
203
|
+
--panel: #fffdf7;
|
|
204
|
+
--line: #d7ded6;
|
|
205
|
+
--green: #19865c;
|
|
206
|
+
--green-soft: #dff3e9;
|
|
207
|
+
--orange: #e86637;
|
|
208
|
+
--shadow: 0 28px 80px rgba(18, 51, 42, .16);
|
|
209
|
+
}
|
|
210
|
+
* { box-sizing: border-box; }
|
|
211
|
+
body {
|
|
212
|
+
margin: 0;
|
|
213
|
+
min-height: 100vh;
|
|
214
|
+
color: var(--ink);
|
|
215
|
+
background:
|
|
216
|
+
radial-gradient(circle at 85% 12%, rgba(232, 102, 55, .13), transparent 28rem),
|
|
217
|
+
linear-gradient(rgba(22, 51, 43, .045) 1px, transparent 1px),
|
|
218
|
+
linear-gradient(90deg, rgba(22, 51, 43, .045) 1px, transparent 1px),
|
|
219
|
+
var(--paper);
|
|
220
|
+
background-size: auto, 32px 32px, 32px 32px, auto;
|
|
221
|
+
font-family: "Aptos", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
222
|
+
}
|
|
223
|
+
main {
|
|
224
|
+
width: min(1080px, calc(100% - 36px));
|
|
225
|
+
min-height: 100vh;
|
|
226
|
+
margin: 0 auto;
|
|
227
|
+
display: grid;
|
|
228
|
+
place-items: center;
|
|
229
|
+
padding: 48px 0;
|
|
230
|
+
}
|
|
231
|
+
.shell {
|
|
232
|
+
width: 100%;
|
|
233
|
+
overflow: hidden;
|
|
234
|
+
display: grid;
|
|
235
|
+
grid-template-columns: 11rem 1fr;
|
|
236
|
+
background: var(--panel);
|
|
237
|
+
border: 1px solid rgba(22, 51, 43, .16);
|
|
238
|
+
border-radius: 28px;
|
|
239
|
+
box-shadow: var(--shadow);
|
|
240
|
+
animation: arrive .55s cubic-bezier(.2, .8, .2, 1) both;
|
|
241
|
+
}
|
|
242
|
+
.rail {
|
|
243
|
+
position: relative;
|
|
244
|
+
padding: 34px 28px;
|
|
245
|
+
color: #f8fff9;
|
|
246
|
+
background: var(--ink);
|
|
247
|
+
display: flex;
|
|
248
|
+
flex-direction: column;
|
|
249
|
+
justify-content: space-between;
|
|
250
|
+
gap: 80px;
|
|
251
|
+
}
|
|
252
|
+
.rail::after {
|
|
253
|
+
content: "";
|
|
254
|
+
position: absolute;
|
|
255
|
+
width: 130px;
|
|
256
|
+
height: 130px;
|
|
257
|
+
right: -78px;
|
|
258
|
+
bottom: 62px;
|
|
259
|
+
border: 1px solid rgba(255, 255, 255, .17);
|
|
260
|
+
border-radius: 50%;
|
|
261
|
+
}
|
|
262
|
+
.mark {
|
|
263
|
+
width: 58px;
|
|
264
|
+
height: 58px;
|
|
265
|
+
border: 1px solid rgba(255, 255, 255, .45);
|
|
266
|
+
border-radius: 50%;
|
|
267
|
+
display: grid;
|
|
268
|
+
place-items: center;
|
|
269
|
+
font: 700 22px/1 Georgia, "Noto Serif SC", serif;
|
|
270
|
+
letter-spacing: -.08em;
|
|
271
|
+
}
|
|
272
|
+
.rail-label {
|
|
273
|
+
writing-mode: vertical-rl;
|
|
274
|
+
transform: rotate(180deg);
|
|
275
|
+
font-size: 12px;
|
|
276
|
+
letter-spacing: .18em;
|
|
277
|
+
text-transform: uppercase;
|
|
278
|
+
opacity: .7;
|
|
279
|
+
}
|
|
280
|
+
.content { padding: clamp(34px, 6vw, 72px); }
|
|
281
|
+
.status {
|
|
282
|
+
display: inline-flex;
|
|
283
|
+
align-items: center;
|
|
284
|
+
gap: 9px;
|
|
285
|
+
padding: 7px 11px;
|
|
286
|
+
color: #126544;
|
|
287
|
+
background: var(--green-soft);
|
|
288
|
+
border-radius: 999px;
|
|
289
|
+
font-size: 13px;
|
|
290
|
+
font-weight: 700;
|
|
291
|
+
letter-spacing: .04em;
|
|
292
|
+
}
|
|
293
|
+
.status::before {
|
|
294
|
+
content: "";
|
|
295
|
+
width: 8px;
|
|
296
|
+
height: 8px;
|
|
297
|
+
background: var(--green);
|
|
298
|
+
border-radius: 50%;
|
|
299
|
+
box-shadow: 0 0 0 4px rgba(25, 134, 92, .13);
|
|
300
|
+
}
|
|
301
|
+
h1 {
|
|
302
|
+
max-width: 700px;
|
|
303
|
+
margin: 25px 0 18px;
|
|
304
|
+
font: 700 clamp(38px, 7vw, 72px)/.98 Georgia, "Noto Serif SC", serif;
|
|
305
|
+
letter-spacing: -.055em;
|
|
306
|
+
}
|
|
307
|
+
h1 span { color: var(--orange); }
|
|
308
|
+
.lead {
|
|
309
|
+
max-width: 720px;
|
|
310
|
+
margin: 0;
|
|
311
|
+
color: var(--muted);
|
|
312
|
+
font-size: clamp(16px, 2vw, 19px);
|
|
313
|
+
line-height: 1.75;
|
|
314
|
+
}
|
|
315
|
+
.endpoint {
|
|
316
|
+
margin: 32px 0;
|
|
317
|
+
padding: 17px 19px;
|
|
318
|
+
display: grid;
|
|
319
|
+
grid-template-columns: auto 1fr;
|
|
320
|
+
gap: 14px;
|
|
321
|
+
align-items: center;
|
|
322
|
+
background: #eef1eb;
|
|
323
|
+
border: 1px solid var(--line);
|
|
324
|
+
border-left: 4px solid var(--orange);
|
|
325
|
+
border-radius: 12px;
|
|
326
|
+
}
|
|
327
|
+
.endpoint strong {
|
|
328
|
+
color: var(--muted);
|
|
329
|
+
font-size: 11px;
|
|
330
|
+
letter-spacing: .14em;
|
|
331
|
+
text-transform: uppercase;
|
|
332
|
+
}
|
|
333
|
+
code {
|
|
334
|
+
overflow-wrap: anywhere;
|
|
335
|
+
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
|
336
|
+
font-size: 14px;
|
|
337
|
+
}
|
|
338
|
+
.steps {
|
|
339
|
+
margin: 0;
|
|
340
|
+
padding: 0;
|
|
341
|
+
list-style: none;
|
|
342
|
+
display: grid;
|
|
343
|
+
grid-template-columns: repeat(3, 1fr);
|
|
344
|
+
border-top: 1px solid var(--line);
|
|
345
|
+
border-bottom: 1px solid var(--line);
|
|
346
|
+
}
|
|
347
|
+
.steps li {
|
|
348
|
+
min-height: 124px;
|
|
349
|
+
padding: 22px 20px 22px 0;
|
|
350
|
+
color: var(--muted);
|
|
351
|
+
font-size: 14px;
|
|
352
|
+
line-height: 1.65;
|
|
353
|
+
}
|
|
354
|
+
.steps li + li {
|
|
355
|
+
padding-left: 20px;
|
|
356
|
+
border-left: 1px solid var(--line);
|
|
357
|
+
}
|
|
358
|
+
.steps b {
|
|
359
|
+
display: block;
|
|
360
|
+
margin-bottom: 8px;
|
|
361
|
+
color: var(--ink);
|
|
362
|
+
font: 700 18px/1.2 Georgia, "Noto Serif SC", serif;
|
|
363
|
+
}
|
|
364
|
+
.steps em {
|
|
365
|
+
display: inline-block;
|
|
366
|
+
margin-right: 6px;
|
|
367
|
+
color: var(--orange);
|
|
368
|
+
font: normal 700 12px/1 "Cascadia Mono", monospace;
|
|
369
|
+
}
|
|
370
|
+
nav {
|
|
371
|
+
margin-top: 24px;
|
|
372
|
+
display: flex;
|
|
373
|
+
flex-wrap: wrap;
|
|
374
|
+
gap: 10px 24px;
|
|
375
|
+
}
|
|
376
|
+
nav a {
|
|
377
|
+
color: var(--ink);
|
|
378
|
+
font-size: 13px;
|
|
379
|
+
font-weight: 700;
|
|
380
|
+
text-decoration-color: rgba(232, 102, 55, .65);
|
|
381
|
+
text-underline-offset: 5px;
|
|
382
|
+
}
|
|
383
|
+
nav a:hover { color: var(--orange); }
|
|
384
|
+
footer {
|
|
385
|
+
margin-top: 38px;
|
|
386
|
+
color: #85948f;
|
|
387
|
+
font-size: 12px;
|
|
388
|
+
letter-spacing: .04em;
|
|
389
|
+
}
|
|
390
|
+
@keyframes arrive {
|
|
391
|
+
from { opacity: 0; transform: translateY(14px); }
|
|
392
|
+
to { opacity: 1; transform: translateY(0); }
|
|
393
|
+
}
|
|
394
|
+
@media (max-width: 720px) {
|
|
395
|
+
main { width: min(100% - 22px, 680px); padding: 18px 0; }
|
|
396
|
+
.shell { grid-template-columns: 1fr; border-radius: 20px; }
|
|
397
|
+
.rail { padding: 18px 22px; flex-direction: row; align-items: center; gap: 20px; }
|
|
398
|
+
.mark { width: 44px; height: 44px; font-size: 17px; }
|
|
399
|
+
.rail-label { writing-mode: horizontal-tb; transform: none; }
|
|
400
|
+
.content { padding: 30px 22px 34px; }
|
|
401
|
+
h1 { letter-spacing: -.04em; }
|
|
402
|
+
.endpoint { grid-template-columns: 1fr; gap: 8px; }
|
|
403
|
+
.steps { grid-template-columns: 1fr; }
|
|
404
|
+
.steps li { min-height: auto; padding: 18px 0; }
|
|
405
|
+
.steps li + li { padding-left: 0; border-left: 0; border-top: 1px solid var(--line); }
|
|
406
|
+
}
|
|
407
|
+
@media (prefers-reduced-motion: reduce) {
|
|
408
|
+
.shell { animation: none; }
|
|
409
|
+
}
|
|
410
|
+
</style>
|
|
411
|
+
</head>
|
|
412
|
+
<body>
|
|
413
|
+
<main>
|
|
414
|
+
<section class="shell" aria-labelledby="page-title">
|
|
415
|
+
<aside class="rail" aria-label="WukongCRM MCP">
|
|
416
|
+
<div class="mark" aria-hidden="true">72</div>
|
|
417
|
+
<div class="rail-label">Model Context Protocol</div>
|
|
418
|
+
</aside>
|
|
419
|
+
<div class="content">
|
|
420
|
+
<div class="status">服务在线</div>
|
|
421
|
+
<h1 id="page-title">WukongCRM <span>MCP</span></h1>
|
|
422
|
+
<p class="lead">这是一个受 OAuth 2.0 保护的远程 MCP 端点。请在支持远程 MCP 的客户端中添加服务地址,由客户端发起授权;直接打开本页不会创建授权会话。</p>
|
|
423
|
+
|
|
424
|
+
<div class="endpoint">
|
|
425
|
+
<strong>服务地址</strong>
|
|
426
|
+
<code>${endpoint}</code>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
<ol class="steps" aria-label="接入步骤">
|
|
430
|
+
<li><b><em>01</em>添加服务</b>在客户端的 MCP 设置中填写上方地址。</li>
|
|
431
|
+
<li><b><em>02</em>完成授权</b>按客户端提示登录 72CRM 并确认 OAuth 授权。</li>
|
|
432
|
+
<li><b><em>03</em>开始使用</b>返回客户端,调用只读工具验证连接状态。</li>
|
|
433
|
+
</ol>
|
|
434
|
+
|
|
435
|
+
<nav aria-label="服务诊断">
|
|
436
|
+
<a href="${healthUrl}">健康检查</a>
|
|
437
|
+
<a href="${metadataUrl}">MCP 资源元数据</a>
|
|
438
|
+
<a href="${authorizationMetadataUrl}">OAuth 服务元数据</a>
|
|
439
|
+
</nav>
|
|
440
|
+
<footer>WukongCRM MCP · Streamable HTTP · OAuth 2.0</footer>
|
|
441
|
+
</div>
|
|
442
|
+
</section>
|
|
443
|
+
</main>
|
|
444
|
+
</body>
|
|
445
|
+
</html>`);
|
|
446
|
+
}
|
|
447
|
+
function escapeHtml(value) {
|
|
448
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
449
|
+
switch (character) {
|
|
450
|
+
case "&": return "&";
|
|
451
|
+
case "<": return "<";
|
|
452
|
+
case ">": return ">";
|
|
453
|
+
case '"': return """;
|
|
454
|
+
case "'": return "'";
|
|
455
|
+
default: return character;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function sendMethodNotAllowed(res) {
|
|
460
|
+
res.status(405).json({
|
|
461
|
+
jsonrpc: "2.0",
|
|
462
|
+
error: { code: -32000, message: "Method not allowed" },
|
|
463
|
+
id: null
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
function uniqueStrings(values) {
|
|
467
|
+
return [...new Set(values.filter(Boolean))];
|
|
468
|
+
}
|
|
469
|
+
export function loadRemoteServerEnvironment(env = process.env) {
|
|
470
|
+
const publicUrl = requiredEnvironment(env, "MCP_PUBLIC_URL");
|
|
471
|
+
const oauthSecret = requiredEnvironment(env, "MCP_OAUTH_SECRET");
|
|
472
|
+
const parsedPublicUrl = validatePublicUrl(publicUrl);
|
|
473
|
+
const crmBindingAuthorizeUrl = requiredEnvironment(env, "CRM_BINDING_AUTHORIZE_URL");
|
|
474
|
+
const crmBindingExchangeUrl = requiredEnvironment(env, "CRM_BINDING_EXCHANGE_URL");
|
|
475
|
+
const crmBindingClientId = requiredEnvironment(env, "CRM_BINDING_CLIENT_ID");
|
|
476
|
+
const crmBindingClientSecret = requiredEnvironment(env, "CRM_BINDING_CLIENT_SECRET");
|
|
477
|
+
const resourcePath = parsedPublicUrl.pathname;
|
|
478
|
+
const crmBindingCallbackUrl = env.CRM_BINDING_CALLBACK_URL?.trim() ||
|
|
479
|
+
new URL(`${resourcePath}/oauth/72crm/callback`, parsedPublicUrl).href;
|
|
480
|
+
const portText = env.MCP_PORT ?? env.PORT ?? "3000";
|
|
481
|
+
const port = Number.parseInt(portText, 10);
|
|
482
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
483
|
+
throw new Error("MCP_PORT/PORT 必须是 1 到 65535 之间的整数。");
|
|
484
|
+
}
|
|
485
|
+
const configuredHosts = (env.MCP_ALLOWED_HOSTS ?? "")
|
|
486
|
+
.split(",")
|
|
487
|
+
.map((value) => value.trim())
|
|
488
|
+
.filter(Boolean);
|
|
489
|
+
return {
|
|
490
|
+
publicUrl,
|
|
491
|
+
oauthSecret,
|
|
492
|
+
dataDirectory: path.resolve(env.MCP_DATA_DIR ?? ".wukong-mcp-data"),
|
|
493
|
+
redisUrl: env.MCP_REDIS_URL?.trim() || undefined,
|
|
494
|
+
redisKeyPrefix: env.MCP_REDIS_PREFIX?.trim() || "wukong-mcp:oauth",
|
|
495
|
+
crmBaseUrl: env.CRM_BASE_URL,
|
|
496
|
+
crmBindingAuthorizeUrl,
|
|
497
|
+
crmBindingExchangeUrl,
|
|
498
|
+
crmBindingExchangeAllowHttp: parseBooleanEnvironment(env.CRM_BINDING_EXCHANGE_ALLOW_HTTP ?? "false", "CRM_BINDING_EXCHANGE_ALLOW_HTTP"),
|
|
499
|
+
crmBindingCallbackUrl,
|
|
500
|
+
crmBindingClientId,
|
|
501
|
+
crmBindingClientSecret,
|
|
502
|
+
host: env.MCP_HOST ?? "127.0.0.1",
|
|
503
|
+
allowedHosts: uniqueStrings([
|
|
504
|
+
...configuredHosts,
|
|
505
|
+
parsedPublicUrl.hostname,
|
|
506
|
+
"localhost",
|
|
507
|
+
"127.0.0.1",
|
|
508
|
+
"[::1]"
|
|
509
|
+
]),
|
|
510
|
+
trustProxy: parseTrustProxy(env.MCP_TRUST_PROXY ?? "1"),
|
|
511
|
+
port
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function parseTrustProxy(value) {
|
|
515
|
+
const normalized = value.trim();
|
|
516
|
+
if (!normalized) {
|
|
517
|
+
throw new Error("MCP_TRUST_PROXY 不能为空。");
|
|
518
|
+
}
|
|
519
|
+
if (normalized === "true") {
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
if (normalized === "false") {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
if (/^-?\d+$/.test(normalized)) {
|
|
526
|
+
const hops = Number.parseInt(normalized, 10);
|
|
527
|
+
if (!Number.isSafeInteger(hops) || hops < 0) {
|
|
528
|
+
throw new Error("MCP_TRUST_PROXY 的代理层数必须是非负整数。");
|
|
529
|
+
}
|
|
530
|
+
return hops;
|
|
531
|
+
}
|
|
532
|
+
return normalized;
|
|
533
|
+
}
|
|
534
|
+
function parseBooleanEnvironment(value, name) {
|
|
535
|
+
const normalized = value.trim().toLowerCase();
|
|
536
|
+
if (normalized === "true") {
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
if (normalized === "false") {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
throw new Error(`${name} 必须为 true 或 false。`);
|
|
543
|
+
}
|
|
544
|
+
function requiredEnvironment(env, name) {
|
|
545
|
+
const value = env[name]?.trim();
|
|
546
|
+
if (!value) {
|
|
547
|
+
throw new Error(`远程模式必须配置 ${name}。`);
|
|
548
|
+
}
|
|
549
|
+
return value;
|
|
550
|
+
}
|
package/dist/modules.js
CHANGED
|
@@ -41,9 +41,9 @@ export const CRM_MODULES = [
|
|
|
41
41
|
basePath: "/crmProduct",
|
|
42
42
|
primaryKey: "productId",
|
|
43
43
|
aliases: ["产品", "crmProduct", "4"],
|
|
44
|
-
supportedOperations: [...COMMON_READ, "简要实体", "销售产品列表"],
|
|
45
|
-
unsupportedOperations: ["
|
|
46
|
-
writable:
|
|
44
|
+
supportedOperations: [...COMMON_READ, "简要实体", "销售产品列表", "新增", "更新", "字段更新", "上下架", "负责人转移", "删除"],
|
|
45
|
+
unsupportedOperations: ["导入导出", "文件流下载", "multipart 文件上传代理"],
|
|
46
|
+
writable: true
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
key: "business",
|
|
@@ -167,7 +167,7 @@ export const CRM_MODULES = [
|
|
|
167
167
|
writable: false
|
|
168
168
|
}
|
|
169
169
|
];
|
|
170
|
-
export const WRITE_MODULES = new Set(["leads", "customer", "contacts", "business"]);
|
|
170
|
+
export const WRITE_MODULES = new Set(["leads", "customer", "contacts", "product", "business"]);
|
|
171
171
|
const MODULE_LOOKUP = new Map();
|
|
172
172
|
for (const moduleDefinition of CRM_MODULES) {
|
|
173
173
|
MODULE_LOOKUP.set(moduleDefinition.key.toLowerCase(), moduleDefinition);
|