@onvcodes/mcp-server 0.0.0-stage → 1.0.1
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 +181 -2
- package/dist/server.cjs +514 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +33 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.js +507 -0
- package/dist/server.js.map +1 -0
- package/package.json +48 -4
package/README.md
CHANGED
|
@@ -1,3 +1,182 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @onvcodes/mcp-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ONV 平台的 [MCP (Model Context Protocol)](https://modelcontextprotocol.io) Server。
|
|
4
|
+
把平台的**行情数据、公开 Agent/策略、回测、订阅、AI 生成**等能力暴露为 MCP 工具,
|
|
5
|
+
供 **Claude Desktop / Cursor / Cline** 等 AI Agent 调用。
|
|
6
|
+
|
|
7
|
+
## 架构:平台工具薄代理
|
|
8
|
+
|
|
9
|
+
MCP Server **不本地硬编码工具**(避免与平台双套定义漂移),采用运行时动态发现:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
MCP Client (Claude / Cursor …)
|
|
13
|
+
│ Streamable HTTP(远程,推荐)/ SSE(兼容)/ stdio(本地)
|
|
14
|
+
▼
|
|
15
|
+
mcp-server(本包)
|
|
16
|
+
│ ① 启动/会话时 GET {baseUrl}/api/mcp/platform/tools → 发现工具清单(JSON Schema)
|
|
17
|
+
│ ② 客户端调用工具 POST {baseUrl}/api/mcp/platform/{tool} body { args }
|
|
18
|
+
│ 凭证按会话透传:Authorization: Bearer > X-Api-Key > 匿名(仅公开工具)
|
|
19
|
+
▼
|
|
20
|
+
onv-backend(平台 /api/mcp/platform/*,3 通道鉴权:X-Api-Key / ?at= / Bearer JWT)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- 工具清单与执行端点由 **onv-backend 统一承载**(`backend/routes/platform-mcp.ts`),
|
|
24
|
+
本服务只是把平台工具翻译成 MCP 协议(zod schema 注册 + 调用转发)。
|
|
25
|
+
- 平台不可达 / 未配置 API Key / 工具端点 404 时,注册一个只读 `__platform_health`
|
|
26
|
+
兜底工具报告状态(tools/list 不为空,便于排查),正常时**注册全部平台工具**。
|
|
27
|
+
|
|
28
|
+
## 平台工具(当前 41 个)
|
|
29
|
+
|
|
30
|
+
`tools/list` 返回 41 个平台工具,按权限分层,并按 4 组展示(`market` / `strategy` / `portfolio` / `account`):
|
|
31
|
+
|
|
32
|
+
| 分层 | 数量 | 说明 |
|
|
33
|
+
|------|:---:|------|
|
|
34
|
+
| L0 公开读(无需身份) | 10 | `market_*` · `symbol_search` · `quick_analysis` · `list_agents` · `get_agent` · `plans` · `wallet_balance` |
|
|
35
|
+
| L1 用户读(需 API Key / 登录绑定用户) | 15 | `my_strategies` · `backtest_*` · `subscription_*` · `user_*` · `account_overview` · `positions` · `trade_history` · `credentials` · `risk_config` · `factory_runs` · `dex_quote` |
|
|
36
|
+
| L2 写操作(需授权 scope) | 16 | `run_backtest` · `manage_strategy` · `publish_strategy` · `subscribe_*` · `ai_generate` · `ai_optimize` · `place_order` · `cancel_order` · `close_position` · `flat_all` · `set_risk_config` · `set_breaker` · `reset_breaker` · `dex_swap` · `factory_run` · `close_preview` |
|
|
37
|
+
|
|
38
|
+
> 每个工具都带 MCP `annotations`:`title`(中文短名)、`readOnlyHint`(读为 true)、
|
|
39
|
+
> `destructiveHint`(写为 true)——满足 Claude Connectors 目录的硬性要求。
|
|
40
|
+
> 展示元数据集中在 [`backend/services/platform-tools/tool-meta.ts`](../backend/services/platform-tools/tool-meta.ts)。
|
|
41
|
+
|
|
42
|
+
> 工具定义会随平台演进自动更新:改动只发生在 backend 的
|
|
43
|
+
> [`PLATFORM_TOOL_DEFS`](../backend/services/platform-tools/registry.ts),MCP Server 无需发版。
|
|
44
|
+
> 后端通过 `ENABLE_PLATFORM_TOOLS=true` 开启工具端点(生产已开启)。
|
|
45
|
+
|
|
46
|
+
## 快速接入(生产,推荐 Streamable HTTP)
|
|
47
|
+
|
|
48
|
+
生产 MCP Server 已随 onv.codes 统一栈部署(容器 `onv-mcp`,nginx `/mcp` 与 `/mcp/` 反代):
|
|
49
|
+
|
|
50
|
+
- **Streamable HTTP 端点(推荐)**:`https://onv.codes/mcp`
|
|
51
|
+
- **SSE 端点(兼容)**:`https://onv.codes/mcp/sse`
|
|
52
|
+
- **鉴权(按会话透传)**:
|
|
53
|
+
- `Authorization: Bearer <token>`(OAuth / 平台 JWT)
|
|
54
|
+
- 或 `X-Api-Key: ah_… / ahk_…`(平台 / Agent Key)
|
|
55
|
+
- 均未提供时按**匿名**处理,只能使用公开工具(行情 / 公开 Agent / 套餐等);
|
|
56
|
+
HTTP 入口不接受服务端 env Key 兜底,避免匿名调用方继承真实账户身份
|
|
57
|
+
|
|
58
|
+
### Claude Desktop
|
|
59
|
+
|
|
60
|
+
Claude Desktop → Settings → Developer → Edit Config → `claude_desktop_config.json`:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"onv": {
|
|
66
|
+
"type": "http",
|
|
67
|
+
"url": "https://onv.codes/mcp",
|
|
68
|
+
"headers": {
|
|
69
|
+
"X-Api-Key": "ah_你的平台APIKey"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
保存后重启 Claude Desktop,对话中即可使用 `platform_market_quote`、`platform_list_agents`、
|
|
77
|
+
`platform_run_backtest`、`platform_ai_generate` 等工具。
|
|
78
|
+
|
|
79
|
+
### Cursor
|
|
80
|
+
|
|
81
|
+
Cursor → Settings → MCP → Add new MCP server:
|
|
82
|
+
|
|
83
|
+
- **Type**:HTTP(远程,推荐)
|
|
84
|
+
- **Name**:`onv`
|
|
85
|
+
- **URL**:`https://onv.codes/mcp`
|
|
86
|
+
- **Headers**:`X-Api-Key: ah_你的平台APIKey`
|
|
87
|
+
|
|
88
|
+
旧版 Cursor 若不支持 HTTP 类型,可退回 SSE:`https://onv.codes/mcp/sse`。
|
|
89
|
+
|
|
90
|
+
### 其他 MCP 客户端
|
|
91
|
+
|
|
92
|
+
支持「Streamable HTTP + 自定义 header」的客户端填 `https://onv.codes/mcp` 与 `X-Api-Key`;
|
|
93
|
+
仅支持 SSE 的客户端填 `https://onv.codes/mcp/sse`。两者共用同一套工具与鉴权。
|
|
94
|
+
|
|
95
|
+
## 本地开发
|
|
96
|
+
|
|
97
|
+
依赖:Node ≥ 18。
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cd mcp-server
|
|
101
|
+
npm install
|
|
102
|
+
npm run build # tsup 打包 ESM + CJS + dts 到 dist/
|
|
103
|
+
npm test # vitest(工具 schema / 注册测试)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
启动:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# stdio 模式(默认,供 Claude Desktop / Cursor 本地拉起)
|
|
110
|
+
ONV_API_KEY=ah_xxx node dist/server.js
|
|
111
|
+
|
|
112
|
+
# HTTP 模式(本地 :3002,同时提供 /mcp 与 /sse)
|
|
113
|
+
ONV_API_KEY=ah_xxx node dist/server.js --http
|
|
114
|
+
# => HTTP server listening on http://localhost:3002 (mcp + sse)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 环境变量
|
|
118
|
+
|
|
119
|
+
| 变量 | 必填 | 默认值 | 说明 |
|
|
120
|
+
| ---- | ---- | ------ | ---- |
|
|
121
|
+
| `ONV_API_KEY` | - | - | 服务端兜底 Key(`ah_` / `ahk_` 前缀)。**客户端自带凭证时优先用客户端的**;均缺省时仅公开工具可用 |
|
|
122
|
+
| `ONV_BASE_URL` | - | `https://onv.codes` | 平台 API 根地址(内网联调可指 `http://localhost:3100`) |
|
|
123
|
+
| `PORT` | - | `3002` | HTTP 模式监听端口 |
|
|
124
|
+
| `MCP_MESSAGE_PATH` | - | `/message` | SSE 握手事件里的消息投递路径;经 nginx 反代时配置(生产为 `/mcp/message`) |
|
|
125
|
+
|
|
126
|
+
### Claude Desktop / Cursor 本地 stdio 配置
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"mcpServers": {
|
|
131
|
+
"onv": {
|
|
132
|
+
"command": "node",
|
|
133
|
+
"args": ["/absolute/path/to/onv/mcp-server/dist/server.js"],
|
|
134
|
+
"env": {
|
|
135
|
+
"ONV_API_KEY": "ah_你的平台APIKey",
|
|
136
|
+
"ONV_BASE_URL": "https://onv.codes"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## API Key 与权限
|
|
144
|
+
|
|
145
|
+
| Key | 前缀 | 用途 | 写权限 |
|
|
146
|
+
| --- | --- | ---- | ---- |
|
|
147
|
+
| 平台 API Key | `ah_` | 外部应用 / MCP 调用,绑定用户 + permissions | permissions 含工具 scope(如 `backtest:run` / `agents:write`)或 `admin` 才能直执写工具 |
|
|
148
|
+
| Agent API Key | `ahk_` | Agent 身份直调 | scopes 含 `write` / `admin` 才能直执写工具 |
|
|
149
|
+
|
|
150
|
+
- 读类(L0)无需鉴权即可调用(返回公开数据)。
|
|
151
|
+
- 用户读(L1)与写(L2)工具要求 `X-Api-Key` 能解析到有效用户(无效/吊销返回 401)。
|
|
152
|
+
- 写工具(L2)在 key 有对应 scope 时**直接执行并落审计**;无 scope 返回 403。
|
|
153
|
+
- 对话场景(`?at=` HMAC token)写操作只落 pending,由前端操作卡确认后执行。
|
|
154
|
+
|
|
155
|
+
## 生产部署(onv.codes 统一栈)
|
|
156
|
+
|
|
157
|
+
- compose 服务:`apps/onv-core/deploy/docker-compose.onv.yml` 的 `mcp`(:3002,
|
|
158
|
+
build context `apps/onv`,dockerfile `mcp-server/Dockerfile`)。
|
|
159
|
+
- nginx:`location = /mcp` / `location /mcp/` 反代到 `mcp:3002`(Streamable HTTP 关 buffering + 3600s 读超时);
|
|
160
|
+
`location /api/mcp/` 反代到 `onv-backend:3100`(工具发现/调用端点)。
|
|
161
|
+
- 凭据:**按会话透传**,不配置服务端 Key(`MCP_API_KEY` 已从 compose 移除)。
|
|
162
|
+
`MCP_BASE_URL` 默认 `https://onv.codes`。
|
|
163
|
+
- 后端开关:`ENABLE_PLATFORM_TOOLS=true`(`platform-tools.ts` 灰度门,生产已置 true)。
|
|
164
|
+
|
|
165
|
+
### 多会话说明
|
|
166
|
+
|
|
167
|
+
MCP SDK 的 Server 实例同一时刻只能 connect 一个 transport(否则抛 `Already connected`)。
|
|
168
|
+
本服务为**每个会话(Streamable HTTP session / SSE transport)创建独立 McpServer 实例**
|
|
169
|
+
(每次会重新发现工具清单),多客户端并发/重连不会互相影响;凭证也随会话隔离。
|
|
170
|
+
|
|
171
|
+
## 故障排查
|
|
172
|
+
|
|
173
|
+
| 现象 | 原因与处理 |
|
|
174
|
+
| --- | --- |
|
|
175
|
+
| `tools/list` 只有 `__platform_health` | 工具发现失败。检查 `ONV_BASE_URL` 可达性、backend `ENABLE_PLATFORM_TOOLS=true`、nginx `/api/mcp/` 反代 |
|
|
176
|
+
| 调用返回 `invalid or revoked api key` | 客户端下发的 `X-Api-Key` / Bearer 无效/已吊销(须 `ah_`/`ahk_` 前缀);匿名调用用户级工具即为此提示 |
|
|
177
|
+
| 调用返回 404 `unknown platform tool` | 工具清单与端点版本不同步(发现后平台已改名),更新 mcp-server 镜像重连 |
|
|
178
|
+
| POST `/message` 502 | 多为 SSE 会话未建立/已断开时投递(sessionId 过期),客户端应重连重新握手 |
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
Apache-2.0
|
package/dist/server.cjs
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
5
|
+
var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
6
|
+
var sse_js = require('@modelcontextprotocol/sdk/server/sse.js');
|
|
7
|
+
var streamableHttp_js = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
8
|
+
var http = require('http');
|
|
9
|
+
var crypto = require('crypto');
|
|
10
|
+
var zod = require('zod');
|
|
11
|
+
|
|
12
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
13
|
+
|
|
14
|
+
var http__default = /*#__PURE__*/_interopDefault(http);
|
|
15
|
+
|
|
16
|
+
// ../packages/sdk-ts/dist/index.js
|
|
17
|
+
var STATUS_CODE = {
|
|
18
|
+
400: "INVALID_REQUEST",
|
|
19
|
+
401: "UNAUTHORIZED",
|
|
20
|
+
403: "FORBIDDEN",
|
|
21
|
+
404: "NOT_FOUND",
|
|
22
|
+
405: "METHOD_NOT_ALLOWED",
|
|
23
|
+
409: "CONFLICT",
|
|
24
|
+
422: "UNPROCESSABLE_ENTITY",
|
|
25
|
+
429: "RATE_LIMITED",
|
|
26
|
+
500: "INTERNAL_ERROR",
|
|
27
|
+
502: "BAD_GATEWAY",
|
|
28
|
+
503: "SERVICE_UNAVAILABLE",
|
|
29
|
+
504: "GATEWAY_TIMEOUT"
|
|
30
|
+
};
|
|
31
|
+
var STATUS_MESSAGE = {
|
|
32
|
+
400: "Bad Request",
|
|
33
|
+
401: "Unauthorized",
|
|
34
|
+
403: "Forbidden",
|
|
35
|
+
404: "Not Found",
|
|
36
|
+
405: "Method Not Allowed",
|
|
37
|
+
409: "Conflict",
|
|
38
|
+
422: "Unprocessable Entity",
|
|
39
|
+
429: "Too Many Requests",
|
|
40
|
+
500: "Internal Server Error",
|
|
41
|
+
502: "Bad Gateway",
|
|
42
|
+
503: "Service Unavailable",
|
|
43
|
+
504: "Gateway Timeout"
|
|
44
|
+
};
|
|
45
|
+
function stringifyDetail(value) {
|
|
46
|
+
if (typeof value === "string") return value;
|
|
47
|
+
if (value === void 0 || value === null) return "";
|
|
48
|
+
try {
|
|
49
|
+
return JSON.stringify(value);
|
|
50
|
+
} catch {
|
|
51
|
+
return String(value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
var ONVError = class _ONVError extends Error {
|
|
55
|
+
/** 错误码(string)。 */
|
|
56
|
+
code;
|
|
57
|
+
/** HTTP 状态码;网络错误为 0。 */
|
|
58
|
+
statusCode;
|
|
59
|
+
/** 详细说明。 */
|
|
60
|
+
detail;
|
|
61
|
+
/** 可选的处理建议。 */
|
|
62
|
+
action;
|
|
63
|
+
constructor(message, code, statusCode, detail = "", action) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "ONVError";
|
|
66
|
+
this.code = code;
|
|
67
|
+
this.statusCode = statusCode;
|
|
68
|
+
this.detail = detail;
|
|
69
|
+
this.action = action;
|
|
70
|
+
}
|
|
71
|
+
/** 是否为网络层错误(fetch 失败 / 超时)。 */
|
|
72
|
+
get isNetworkError() {
|
|
73
|
+
return this.code === "NETWORK_ERROR";
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 从 HTTP 响应构造错误。
|
|
77
|
+
* 响应体按 `{ code, detail, action }` 解析(同时兼容 `{ message }`);
|
|
78
|
+
* 缺少 code 时按 statusCode 映射(如 404 -> NOT_FOUND)。
|
|
79
|
+
*/
|
|
80
|
+
static fromHttpResponse(status, body) {
|
|
81
|
+
const parsed = body && typeof body === "object" ? body : {};
|
|
82
|
+
const rawCode = parsed.code;
|
|
83
|
+
const code = typeof rawCode === "string" || typeof rawCode === "number" ? String(rawCode) : STATUS_CODE[status] ?? `HTTP_${status}`;
|
|
84
|
+
const detail = stringifyDetail(parsed.detail);
|
|
85
|
+
const action = typeof parsed.action === "string" ? parsed.action : void 0;
|
|
86
|
+
const message = typeof parsed.message === "string" && parsed.message ? parsed.message : detail ? `HTTP ${status}: ${detail}` : `HTTP ${status} ${STATUS_MESSAGE[status] ?? ""}`.trim();
|
|
87
|
+
return new _ONVError(message, code, status, detail, action);
|
|
88
|
+
}
|
|
89
|
+
/** 网络失败(fetch 抛错 / 超时 / DNS 等)→ code = NETWORK_ERROR, statusCode = 0。 */
|
|
90
|
+
static networkError(original) {
|
|
91
|
+
const cause = original instanceof Error ? original : new Error(String(original));
|
|
92
|
+
return new _ONVError(
|
|
93
|
+
`Network error: ${cause.message}`,
|
|
94
|
+
"NETWORK_ERROR",
|
|
95
|
+
0,
|
|
96
|
+
cause.message
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/auth.ts
|
|
102
|
+
function headerValue(value) {
|
|
103
|
+
if (Array.isArray(value)) return value[0]?.trim() ?? "";
|
|
104
|
+
return (value ?? "").trim();
|
|
105
|
+
}
|
|
106
|
+
function resolveCredential(headers) {
|
|
107
|
+
const authorization = headerValue(headers.authorization);
|
|
108
|
+
if (authorization) {
|
|
109
|
+
const matched = /^Bearer\s+(.+)$/i.exec(authorization);
|
|
110
|
+
if (matched?.[1]?.trim()) {
|
|
111
|
+
return { type: "bearer", accessToken: matched[1].trim() };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const apiKey = headerValue(headers["x-api-key"]);
|
|
115
|
+
if (apiKey) return { type: "apikey", apiKey };
|
|
116
|
+
return { type: "anonymous" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/oauth.ts
|
|
120
|
+
function oauthIssuer() {
|
|
121
|
+
return String(process.env.MCP_OAUTH_ISSUER || "https://onv.codes").replace(/\/+$/, "");
|
|
122
|
+
}
|
|
123
|
+
function resourceIdentifier() {
|
|
124
|
+
return String(process.env.MCP_RESOURCE_URL || `${oauthIssuer()}/mcp`).replace(/\/+$/, "");
|
|
125
|
+
}
|
|
126
|
+
function protectedResourceMetadataUrl() {
|
|
127
|
+
return `${oauthIssuer()}/.well-known/oauth-protected-resource`;
|
|
128
|
+
}
|
|
129
|
+
function protectedResourceMetadata() {
|
|
130
|
+
return {
|
|
131
|
+
resource: resourceIdentifier(),
|
|
132
|
+
authorization_servers: [oauthIssuer()],
|
|
133
|
+
bearer_methods_supported: ["header"],
|
|
134
|
+
scopes_supported: ["mcp:read", "mcp:write", "mcp:trade", "mcp:backtest"],
|
|
135
|
+
resource_name: "ONV Platform MCP",
|
|
136
|
+
resource_documentation: `${oauthIssuer()}/mcp-setup`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function oauthChallengeEnabled() {
|
|
140
|
+
return String(process.env.MCP_OAUTH_CHALLENGE || "").toLowerCase() === "true";
|
|
141
|
+
}
|
|
142
|
+
function hasCredential(headers) {
|
|
143
|
+
const auth = headers.authorization;
|
|
144
|
+
const apiKey = headers["x-api-key"];
|
|
145
|
+
return Boolean((Array.isArray(auth) ? auth[0] : auth) || (Array.isArray(apiKey) ? apiKey[0] : apiKey));
|
|
146
|
+
}
|
|
147
|
+
function sendOAuthChallenge(res) {
|
|
148
|
+
res.writeHead(401, {
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
"WWW-Authenticate": `Bearer resource_metadata="${protectedResourceMetadataUrl()}"`
|
|
151
|
+
});
|
|
152
|
+
res.end(JSON.stringify({
|
|
153
|
+
jsonrpc: "2.0",
|
|
154
|
+
error: { code: -32001, message: "Unauthorized: OAuth bearer token required" },
|
|
155
|
+
id: null
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/config.ts
|
|
160
|
+
function loadConfig(env = process.env) {
|
|
161
|
+
return {
|
|
162
|
+
apiKey: env.ONV_API_KEY || void 0,
|
|
163
|
+
baseUrl: env.ONV_BASE_URL || "https://onv.codes"
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function jsonSchemaToZod(schema) {
|
|
167
|
+
const shape = {};
|
|
168
|
+
if (!schema || typeof schema !== "object") return shape;
|
|
169
|
+
const props = schema.properties ?? {};
|
|
170
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
171
|
+
for (const [key, p] of Object.entries(props)) {
|
|
172
|
+
const desc = p?.description ?? "";
|
|
173
|
+
let zt;
|
|
174
|
+
switch (p?.type) {
|
|
175
|
+
case "number":
|
|
176
|
+
zt = zod.z.number();
|
|
177
|
+
break;
|
|
178
|
+
case "boolean":
|
|
179
|
+
zt = zod.z.boolean();
|
|
180
|
+
break;
|
|
181
|
+
case "array":
|
|
182
|
+
zt = zod.z.array(zod.z.any());
|
|
183
|
+
break;
|
|
184
|
+
case "object":
|
|
185
|
+
zt = zod.z.record(zod.z.string(), zod.z.any());
|
|
186
|
+
break;
|
|
187
|
+
default:
|
|
188
|
+
zt = zod.z.string();
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
let field = required.includes(key) ? zt : zt.optional();
|
|
192
|
+
if (desc) field = field.describe(desc);
|
|
193
|
+
shape[key] = field;
|
|
194
|
+
}
|
|
195
|
+
return shape;
|
|
196
|
+
}
|
|
197
|
+
async function safeRun(fn) {
|
|
198
|
+
try {
|
|
199
|
+
const result = await fn();
|
|
200
|
+
const text = result === void 0 ? "OK" : JSON.stringify(result, null, 2);
|
|
201
|
+
return { content: [{ type: "text", text }] };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return {
|
|
204
|
+
content: [
|
|
205
|
+
{
|
|
206
|
+
type: "text",
|
|
207
|
+
text: JSON.stringify(
|
|
208
|
+
{ error: { code: "PROXY_ERROR", message: err?.message || String(err) } },
|
|
209
|
+
null,
|
|
210
|
+
2
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
],
|
|
214
|
+
isError: true
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function discoverTools(config) {
|
|
219
|
+
try {
|
|
220
|
+
const res = await fetch(`${config.baseUrl.replace(/\/+$/, "")}/api/mcp/platform/tools`, {
|
|
221
|
+
signal: AbortSignal.timeout(15e3)
|
|
222
|
+
});
|
|
223
|
+
if (!res.ok) return null;
|
|
224
|
+
const json = await res.json().catch(() => null);
|
|
225
|
+
return json?.data?.tools ?? null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
var GROUP_LABELS = {
|
|
231
|
+
market: "\u884C\u60C5",
|
|
232
|
+
strategy: "\u7B56\u7565",
|
|
233
|
+
portfolio: "\u6301\u4ED3\u4E0E\u6267\u884C",
|
|
234
|
+
account: "\u8D26\u6237\u4E0E\u989D\u5EA6"
|
|
235
|
+
};
|
|
236
|
+
function humanizeToolName(name) {
|
|
237
|
+
return name.replace(/^platform_/, "").replace(/_/g, " ");
|
|
238
|
+
}
|
|
239
|
+
async function registerTools(server, config) {
|
|
240
|
+
const base = config.baseUrl.replace(/\/+$/, "");
|
|
241
|
+
const headers = { "content-type": "application/json" };
|
|
242
|
+
if (config.accessToken) headers["authorization"] = `Bearer ${config.accessToken}`;
|
|
243
|
+
else if (config.apiKey) headers["x-api-key"] = config.apiKey;
|
|
244
|
+
const tools = await discoverTools(config);
|
|
245
|
+
if (!tools || tools.length === 0) {
|
|
246
|
+
server.registerTool(
|
|
247
|
+
"__platform_health",
|
|
248
|
+
{
|
|
249
|
+
title: "Platform Health",
|
|
250
|
+
description: "\u5E73\u53F0 MCP \u7AEF\u70B9\u72B6\u6001\u3002\u5DE5\u5177\u672A\u52A0\u8F7D\u65F6\u7528\u4E8E\u6392\u67E5\uFF1A\u68C0\u67E5 ONV_BASE_URL \u4E0E ONV_API_KEY \u914D\u7F6E\u3002",
|
|
251
|
+
inputSchema: {}
|
|
252
|
+
},
|
|
253
|
+
async () => safeRun(() => ({
|
|
254
|
+
ok: false,
|
|
255
|
+
error: "platform tools unavailable",
|
|
256
|
+
detail: tools === null ? "\u65E0\u6CD5\u4ECE\u5E73\u53F0\u83B7\u53D6\u5DE5\u5177\u6E05\u5355\uFF08\u68C0\u67E5 ONV_BASE_URL \u4E0E\u7F51\u7EDC\uFF09" : "\u5E73\u53F0\u5DE5\u5177\u5217\u8868\u4E3A\u7A7A\uFF08\u68C0\u67E5 ENABLE_PLATFORM_TOOLS / PLATFORM_TOOL_PUBLIC_URL\uFF09",
|
|
257
|
+
baseUrl: base,
|
|
258
|
+
hasApiKey: !!config.apiKey
|
|
259
|
+
}))
|
|
260
|
+
);
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
for (const tool of tools) {
|
|
264
|
+
const title = tool.title || humanizeToolName(tool.name);
|
|
265
|
+
const isWrite = tool.mode === "write";
|
|
266
|
+
const groupLabel = tool.group ? GROUP_LABELS[tool.group] : "";
|
|
267
|
+
const prefix = groupLabel ? `\u3010${groupLabel}\u3011` : "";
|
|
268
|
+
const costSuffix = tool.cost?.text ? `\uFF08cost: ${tool.cost.text}\uFF09` : "";
|
|
269
|
+
const suffix = isWrite ? "\uFF08\u5199\u64CD\u4F5C\uFF0C\u5916\u90E8\u8C03\u7528\u5C06\u4EE5 apikey \u8EAB\u4EFD\u76F4\u63A5\u6267\u884C\uFF09" : "";
|
|
270
|
+
server.registerTool(
|
|
271
|
+
tool.name,
|
|
272
|
+
{
|
|
273
|
+
title,
|
|
274
|
+
description: `${prefix}${tool.description ?? ""}${costSuffix}${suffix}`,
|
|
275
|
+
inputSchema: jsonSchemaToZod(tool.inputSchema),
|
|
276
|
+
annotations: {
|
|
277
|
+
title,
|
|
278
|
+
readOnlyHint: !isWrite,
|
|
279
|
+
destructiveHint: isWrite
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
async (args) => safeRun(async () => {
|
|
283
|
+
const res = await fetch(`${base}/api/mcp/platform/${encodeURIComponent(tool.name)}`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers,
|
|
286
|
+
body: JSON.stringify({ args: args ?? {} }),
|
|
287
|
+
signal: AbortSignal.timeout(12e4)
|
|
288
|
+
});
|
|
289
|
+
const text = await res.text();
|
|
290
|
+
let json;
|
|
291
|
+
try {
|
|
292
|
+
json = JSON.parse(text);
|
|
293
|
+
} catch {
|
|
294
|
+
json = text;
|
|
295
|
+
}
|
|
296
|
+
if (!res.ok) {
|
|
297
|
+
throw new Error(`platform tool failed (HTTP ${res.status}): ${text.slice(0, 500)}`);
|
|
298
|
+
}
|
|
299
|
+
return json;
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return tools.length;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/server.ts
|
|
307
|
+
var DEFAULT_PORT = 3002;
|
|
308
|
+
var MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
309
|
+
function configForCredential(credential) {
|
|
310
|
+
const base = loadConfig();
|
|
311
|
+
switch (credential.type) {
|
|
312
|
+
case "bearer":
|
|
313
|
+
return { ...base, apiKey: void 0, accessToken: credential.accessToken };
|
|
314
|
+
case "apikey":
|
|
315
|
+
return { ...base, apiKey: credential.apiKey, accessToken: void 0 };
|
|
316
|
+
default:
|
|
317
|
+
return { ...base, apiKey: void 0, accessToken: void 0 };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async function createMcpServer(config = loadConfig()) {
|
|
321
|
+
const server = new mcp_js.McpServer({
|
|
322
|
+
name: "onv-mcp-server",
|
|
323
|
+
version: "1.0.0"
|
|
324
|
+
});
|
|
325
|
+
const count = await registerTools(server, config);
|
|
326
|
+
console.error(
|
|
327
|
+
`[onv-mcp] registered ${count} platform tools (baseUrl=${config.baseUrl}, auth=${config.accessToken ? "bearer" : config.apiKey ? "apikey" : "anonymous"})`
|
|
328
|
+
);
|
|
329
|
+
return server;
|
|
330
|
+
}
|
|
331
|
+
async function readJsonBody(req) {
|
|
332
|
+
const chunks = [];
|
|
333
|
+
let size = 0;
|
|
334
|
+
for await (const chunk of req) {
|
|
335
|
+
const buf = chunk;
|
|
336
|
+
size += buf.length;
|
|
337
|
+
if (size > MAX_BODY_BYTES) throw new Error("request body too large");
|
|
338
|
+
chunks.push(buf);
|
|
339
|
+
}
|
|
340
|
+
if (size === 0) return void 0;
|
|
341
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
342
|
+
}
|
|
343
|
+
function sendJson(res, status, body) {
|
|
344
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
345
|
+
res.end(JSON.stringify(body));
|
|
346
|
+
}
|
|
347
|
+
function applyCors(res) {
|
|
348
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
349
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
350
|
+
res.setHeader(
|
|
351
|
+
"Access-Control-Allow-Headers",
|
|
352
|
+
"Content-Type, Authorization, X-Api-Key, Mcp-Session-Id, MCP-Protocol-Version"
|
|
353
|
+
);
|
|
354
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
355
|
+
}
|
|
356
|
+
async function startHttpServer(port) {
|
|
357
|
+
const sseTransports = /* @__PURE__ */ new Map();
|
|
358
|
+
const streamTransports = /* @__PURE__ */ new Map();
|
|
359
|
+
const httpServer = http__default.default.createServer(async (req, res) => {
|
|
360
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
361
|
+
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) {
|
|
362
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
363
|
+
res.end(
|
|
364
|
+
"ONV MCP Server is running.\nStreamable HTTP: POST/GET/DELETE /mcp\nSSE (legacy): GET /sse + POST /message\n"
|
|
365
|
+
);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (req.method === "GET" && (url.pathname === "/.well-known/oauth-protected-resource" || url.pathname === "/.well-known/oauth-protected-resource/mcp")) {
|
|
369
|
+
res.writeHead(200, {
|
|
370
|
+
"Content-Type": "application/json",
|
|
371
|
+
"Cache-Control": "public, max-age=300"
|
|
372
|
+
});
|
|
373
|
+
res.end(JSON.stringify(protectedResourceMetadata()));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (url.pathname === "/mcp") {
|
|
377
|
+
applyCors(res);
|
|
378
|
+
if (req.method === "OPTIONS") {
|
|
379
|
+
res.writeHead(204).end();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (oauthChallengeEnabled() && !hasCredential(req.headers)) {
|
|
383
|
+
sendOAuthChallenge(res);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
await handleStreamableHttp(req, res, streamTransports);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (req.method === "GET" && url.pathname === "/sse") {
|
|
390
|
+
if (oauthChallengeEnabled() && !hasCredential(req.headers)) {
|
|
391
|
+
sendOAuthChallenge(res);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const messageEndpoint = process.env.MCP_MESSAGE_PATH || "/message";
|
|
395
|
+
const transport = new sse_js.SSEServerTransport(messageEndpoint, res);
|
|
396
|
+
sseTransports.set(transport.sessionId, transport);
|
|
397
|
+
res.on("close", () => {
|
|
398
|
+
sseTransports.delete(transport.sessionId);
|
|
399
|
+
});
|
|
400
|
+
try {
|
|
401
|
+
const credential = resolveCredential(req.headers);
|
|
402
|
+
const sessionServer = await createMcpServer(configForCredential(credential));
|
|
403
|
+
await sessionServer.connect(transport);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
sseTransports.delete(transport.sessionId);
|
|
406
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
407
|
+
console.error(`[onv-mcp] session ${transport.sessionId} connect failed: ${message}`);
|
|
408
|
+
res.destroy();
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (req.method === "POST" && url.pathname === "/message") {
|
|
413
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
414
|
+
const transport = sessionId ? sseTransports.get(sessionId) : void 0;
|
|
415
|
+
if (!transport) {
|
|
416
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
417
|
+
res.end("No active SSE transport for sessionId");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
await transport.handlePostMessage(req, res);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
424
|
+
res.end("Not Found");
|
|
425
|
+
});
|
|
426
|
+
await new Promise((resolve) => httpServer.listen(port, resolve));
|
|
427
|
+
return httpServer;
|
|
428
|
+
}
|
|
429
|
+
async function handleStreamableHttp(req, res, transports) {
|
|
430
|
+
const rawSessionId = req.headers["mcp-session-id"];
|
|
431
|
+
const sessionId = Array.isArray(rawSessionId) ? rawSessionId[0] : rawSessionId;
|
|
432
|
+
try {
|
|
433
|
+
let transport = sessionId ? transports.get(sessionId) : void 0;
|
|
434
|
+
if (!transport) {
|
|
435
|
+
if (sessionId) {
|
|
436
|
+
sendJson(res, 404, {
|
|
437
|
+
jsonrpc: "2.0",
|
|
438
|
+
error: { code: -32001, message: "Session not found" },
|
|
439
|
+
id: null
|
|
440
|
+
});
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (req.method !== "POST") {
|
|
444
|
+
sendJson(res, 400, {
|
|
445
|
+
jsonrpc: "2.0",
|
|
446
|
+
error: { code: -32e3, message: "Bad Request: Mcp-Session-Id header is required" },
|
|
447
|
+
id: null
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const body = await readJsonBody(req);
|
|
452
|
+
if (!body || body.method !== "initialize") {
|
|
453
|
+
sendJson(res, 400, {
|
|
454
|
+
jsonrpc: "2.0",
|
|
455
|
+
error: { code: -32e3, message: "Bad Request: first request must be initialize" },
|
|
456
|
+
id: null
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const credential = resolveCredential(req.headers);
|
|
461
|
+
const created = new streamableHttp_js.StreamableHTTPServerTransport({
|
|
462
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
463
|
+
onsessioninitialized: (id) => {
|
|
464
|
+
transports.set(id, created);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
created.onclose = () => {
|
|
468
|
+
if (created.sessionId) transports.delete(created.sessionId);
|
|
469
|
+
};
|
|
470
|
+
const sessionServer = await createMcpServer(configForCredential(credential));
|
|
471
|
+
await sessionServer.connect(created);
|
|
472
|
+
await created.handleRequest(req, res, body);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (req.method === "POST") {
|
|
476
|
+
const body = await readJsonBody(req);
|
|
477
|
+
await transport.handleRequest(req, res, body);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
await transport.handleRequest(req, res);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
483
|
+
console.error(`[onv-mcp] streamable http error: ${message}`);
|
|
484
|
+
if (!res.headersSent) {
|
|
485
|
+
sendJson(res, 500, {
|
|
486
|
+
jsonrpc: "2.0",
|
|
487
|
+
error: { code: -32603, message: "Internal server error" },
|
|
488
|
+
id: null
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async function main() {
|
|
494
|
+
const httpMode = process.argv.includes("--sse") || process.argv.includes("--http");
|
|
495
|
+
if (httpMode) {
|
|
496
|
+
const port = Number(process.env.PORT ?? DEFAULT_PORT);
|
|
497
|
+
await startHttpServer(port);
|
|
498
|
+
console.error(`[onv-mcp] HTTP server listening on http://localhost:${port} (mcp + sse)`);
|
|
499
|
+
} else {
|
|
500
|
+
const server = await createMcpServer();
|
|
501
|
+
const transport = new stdio_js.StdioServerTransport();
|
|
502
|
+
await server.connect(transport);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
main().catch((err) => {
|
|
506
|
+
const message = err instanceof ONVError ? err.message : err instanceof Error ? err.message : String(err);
|
|
507
|
+
console.error(`[onv-mcp] failed to start: ${message}`);
|
|
508
|
+
process.exit(1);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
exports.createMcpServer = createMcpServer;
|
|
512
|
+
exports.startHttpServer = startHttpServer;
|
|
513
|
+
//# sourceMappingURL=server.cjs.map
|
|
514
|
+
//# sourceMappingURL=server.cjs.map
|