whalecode-mcp 0.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 +136 -0
- package/dist/client.js +137 -0
- package/dist/index.js +5 -0
- package/dist/registry.js +169 -0
- package/dist/server.js +278 -0
- package/package.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# whalecode-mcp
|
|
2
|
+
|
|
3
|
+
WhaleCode MCP 服务器:把主机(Cursor 等)的工具调用转发到本机已打开的 WhaleCode 窗口。运行路径与侧栏 Composer 相同,审批、hooks、行内审阅仍然生效。
|
|
4
|
+
|
|
5
|
+
前提:
|
|
6
|
+
|
|
7
|
+
- Node.js >= 18
|
|
8
|
+
- 本机已启动带 WhaleCode 扩展的 IDE,且至少打开一个工作区(扩展会在 `onStartupFinished` 后拉起 localhost bridge)
|
|
9
|
+
|
|
10
|
+
本包零运行时依赖,只用 Node 内置模块(`http` / `fs` / `path` / `os`)。
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g whalecode-mcp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 配置 MCP 主机
|
|
19
|
+
|
|
20
|
+
Cursor(`.cursor/mcp.json` 可用 npx:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"whalecode": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "whalecode-mcp"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
传输为 **JSON-RPC 2.0、按行分隔的 STDIO**(`protocolVersion: 2024-11-05`,`serverInfo.name: whalecode`)。配置完成后主机会发现下面三个工具。
|
|
34
|
+
|
|
35
|
+
## 可用工具
|
|
36
|
+
|
|
37
|
+
### `list_sessions`
|
|
38
|
+
|
|
39
|
+
列出当前存活的 WhaleCode 窗口。无参数。
|
|
40
|
+
多窗口时先用这个拿 `sessionId`,再交给 `run_agent`。
|
|
41
|
+
|
|
42
|
+
### `run_agent`
|
|
43
|
+
|
|
44
|
+
向一个已打开的窗口发聊天,并**同步轮询直到终态、取消、出错或超时**(轮询间隔 500ms)。成功后会聚焦 WhaleCode 聊天视图。
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"text": "请解释这个函数的实现逻辑",
|
|
49
|
+
"workspace": "/path/to/project",
|
|
50
|
+
"mode": "agent",
|
|
51
|
+
"model": "deepseek-v4-flash",
|
|
52
|
+
"newChat": true
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
57
|
+
|------|------|------|------|
|
|
58
|
+
| `text` | string | 是 | 发给 WhaleCode 的内容(空串会报错) |
|
|
59
|
+
| `mode` | string | 否 | `agent` / `ask` / `plan` / `multitask` / `project` / `debug`。省略则用窗口当前模式 |
|
|
60
|
+
| `model` | string | 否 | 模型 id 或显示名。省略则用「推荐」列表里第一个**当前可用**的模型 |
|
|
61
|
+
| `workspace` | string | 否 | 按工作区路径选窗口,默认 MCP 进程 `cwd` |
|
|
62
|
+
| `sessionId` | string | 否 | 精确指定窗口,优先于 `workspace` |
|
|
63
|
+
| `conversationId` | string | 否 | 切到已有会话再发;会话不存在则失败 |
|
|
64
|
+
| `newChat` | boolean | 否 | `true` 时先开新会话(优先于 `conversationId`) |
|
|
65
|
+
| `timeoutMs` | number | 否 | 等待终态的上限,默认 `900000`(15 分钟)。须 `> 0` |
|
|
66
|
+
|
|
67
|
+
**窗口路由**(`pickSession`,多匹配时不猜):
|
|
68
|
+
|
|
69
|
+
1. 传了 `sessionId` → 精确匹配,否则报错并带上候选窗口
|
|
70
|
+
2. 否则用 `workspace`(默认 `cwd`)匹配:目标路径等于某 `workspaceFolders` 项,或是其子路径(`/proj` 能匹配 `/proj/src`,不能匹配 `/proj-extra`)
|
|
71
|
+
3. 命中 1 个 → 使用;0 个 → 报「没有打开的窗口」;多个 → 报错,要求传入 `sessionId`,`sessions` 里是候选公开信息
|
|
72
|
+
|
|
73
|
+
**模型解析**:
|
|
74
|
+
|
|
75
|
+
- 传了 `model`:按 picker 的完整 id、`modelId`、裸 id、名称(忽略大小写)匹配;对不上则 `模型不可用:…`
|
|
76
|
+
- 未传:按推荐顺序取第一个可用项:`deepseek-v4-flash` → `qwen3-5-397B` → `kimi-26`。一个都没有则报错,提示在设置里启用其中一个
|
|
77
|
+
- `auto` 不会被选中
|
|
78
|
+
|
|
79
|
+
**会话**:`newChat` → 新建;否则若给了 `conversationId` 必须已存在;再否则用当前会话,没有当前会话则新建。
|
|
80
|
+
|
|
81
|
+
终态返回:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"runId": "run_1710000000000_ab12cd",
|
|
86
|
+
"conversationId": "conv_456",
|
|
87
|
+
"sessionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
88
|
+
"status": "finished",
|
|
89
|
+
"model": "provider::deepseek-v4-flash",
|
|
90
|
+
"assistantText": "……",
|
|
91
|
+
"toolSummary": ["search ok: 找到 3 个结果", "read failed: …"],
|
|
92
|
+
"needsApproval": false
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| `status` | 含义 |
|
|
97
|
+
|----------|------|
|
|
98
|
+
| `finished` | 正常结束 |
|
|
99
|
+
| `cancelled` | 被 `cancel_run` 或 IDE 取消 |
|
|
100
|
+
| `error` | 启动失败或运行出错,见 `error` |
|
|
101
|
+
| `timeout` | 超过 `timeoutMs` 仍为 `running`(工具结果标 `isError`) |
|
|
102
|
+
|
|
103
|
+
超时额外带 `pendingApproval`。若窗口在等用户点审批,`needsApproval` 为 `true`;MCP **不会**代批,需要人在 IDE 里处理,否则会一直 `running` 直到超时。`toolSummary` 来自已完成的 tool-call(`name ok/failed: 首行摘要`)。
|
|
104
|
+
|
|
105
|
+
### `cancel_run`
|
|
106
|
+
|
|
107
|
+
取消一次仍在跑的 `run_agent`。
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{ "runId": "run_1710000000000_ab12cd" }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
114
|
+
|------|------|------|------|
|
|
115
|
+
| `runId` | string | 是 | `run_agent` 返回的 id |
|
|
116
|
+
| `sessionId` | string | 否 | 目标窗口。省略则先查本 MCP 进程启动过的 run,再向各活窗口探测 |
|
|
117
|
+
|
|
118
|
+
定位顺序:显式 `sessionId` → 本进程 `runId` 缓存 → 对其余窗口 `GET /v1/runs/:id`(404 跳过)。找不到则 `{ "error": "run not found: …" }`。
|
|
119
|
+
|
|
120
|
+
成功:`{ "ok": true, "runId": "…", "status": "cancelled", "sessionId": "…" }`。已经结束的 run 会原样返回其当前 `status`。
|
|
121
|
+
|
|
122
|
+
## 工作原理
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
MCP Host
|
|
126
|
+
│ JSON-RPC 2.0(一行一条)over STDIO
|
|
127
|
+
▼
|
|
128
|
+
whalecode-mcp
|
|
129
|
+
│ 读 ~/.whalecode/sessions/*.json
|
|
130
|
+
│ HTTP 127.0.0.1:<port> + Authorization: Bearer <token>
|
|
131
|
+
▼
|
|
132
|
+
WhaleCode 扩展 bridge
|
|
133
|
+
│ 与 Composer 同一条 runExternal
|
|
134
|
+
▼
|
|
135
|
+
侧栏 / 编辑器聊天
|
|
136
|
+
```
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.BridgeHttpError = void 0;
|
|
37
|
+
exports.getHealth = getHealth;
|
|
38
|
+
exports.startRun = startRun;
|
|
39
|
+
exports.getRun = getRun;
|
|
40
|
+
exports.cancelRun = cancelRun;
|
|
41
|
+
exports.findRunSession = findRunSession;
|
|
42
|
+
const http = __importStar(require("http"));
|
|
43
|
+
class BridgeHttpError extends Error {
|
|
44
|
+
status;
|
|
45
|
+
body;
|
|
46
|
+
constructor(message, status, body) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.body = body;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
exports.BridgeHttpError = BridgeHttpError;
|
|
53
|
+
function request(session, method, pathname, body) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
56
|
+
const req = http.request({
|
|
57
|
+
host: "127.0.0.1",
|
|
58
|
+
port: session.port,
|
|
59
|
+
path: pathname,
|
|
60
|
+
method,
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bearer ${session.token}`,
|
|
63
|
+
Accept: "application/json",
|
|
64
|
+
...(payload
|
|
65
|
+
? {
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
},
|
|
71
|
+
}, (res) => {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
res.on("data", (c) => chunks.push(c));
|
|
74
|
+
res.on("end", () => {
|
|
75
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
76
|
+
let parsed = undefined;
|
|
77
|
+
if (raw) {
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
parsed = raw;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const status = res.statusCode || 500;
|
|
86
|
+
if (status >= 400) {
|
|
87
|
+
const errMsg = parsed &&
|
|
88
|
+
typeof parsed === "object" &&
|
|
89
|
+
parsed !== null &&
|
|
90
|
+
"error" in parsed
|
|
91
|
+
? String(parsed.error)
|
|
92
|
+
: `HTTP ${status}`;
|
|
93
|
+
reject(new BridgeHttpError(errMsg, status, parsed));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
resolve(parsed);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
req.on("error", reject);
|
|
100
|
+
req.setTimeout(30_000, () => {
|
|
101
|
+
req.destroy(new Error("bridge request timed out"));
|
|
102
|
+
});
|
|
103
|
+
if (payload)
|
|
104
|
+
req.write(payload);
|
|
105
|
+
req.end();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function getHealth(session) {
|
|
109
|
+
return request(session, "GET", "/v1/health");
|
|
110
|
+
}
|
|
111
|
+
function startRun(session, body) {
|
|
112
|
+
return request(session, "POST", "/v1/run", body);
|
|
113
|
+
}
|
|
114
|
+
function getRun(session, runId) {
|
|
115
|
+
return request(session, "GET", `/v1/runs/${encodeURIComponent(runId)}`);
|
|
116
|
+
}
|
|
117
|
+
function cancelRun(session, runId) {
|
|
118
|
+
return request(session, "POST", `/v1/runs/${encodeURIComponent(runId)}/cancel`);
|
|
119
|
+
}
|
|
120
|
+
async function findRunSession(sessions, runId, known) {
|
|
121
|
+
const ordered = known
|
|
122
|
+
? [known, ...sessions.filter((s) => s.sessionId !== known.sessionId)]
|
|
123
|
+
: sessions;
|
|
124
|
+
for (const session of ordered) {
|
|
125
|
+
try {
|
|
126
|
+
await getRun(session, runId);
|
|
127
|
+
return session;
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (err instanceof BridgeHttpError && err.status === 404)
|
|
131
|
+
continue;
|
|
132
|
+
if (err instanceof BridgeHttpError)
|
|
133
|
+
return session;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
package/dist/index.js
ADDED
package/dist/registry.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.BRIDGE_MODES = exports.BRIDGE_STALE_MS = void 0;
|
|
37
|
+
exports.toPublicSession = toPublicSession;
|
|
38
|
+
exports.sessionsDir = sessionsDir;
|
|
39
|
+
exports.isPidAlive = isPidAlive;
|
|
40
|
+
exports.isSessionLive = isSessionLive;
|
|
41
|
+
exports.workspaceContains = workspaceContains;
|
|
42
|
+
exports.pickSession = pickSession;
|
|
43
|
+
exports.listLiveSessions = listLiveSessions;
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
exports.BRIDGE_STALE_MS = 90_000;
|
|
48
|
+
exports.BRIDGE_MODES = [
|
|
49
|
+
"agent",
|
|
50
|
+
"ask",
|
|
51
|
+
"plan",
|
|
52
|
+
"multitask",
|
|
53
|
+
"project",
|
|
54
|
+
"debug",
|
|
55
|
+
];
|
|
56
|
+
function toPublicSession(record) {
|
|
57
|
+
return {
|
|
58
|
+
sessionId: record.sessionId,
|
|
59
|
+
workspaceFolders: record.workspaceFolders,
|
|
60
|
+
workspaceName: record.workspaceName,
|
|
61
|
+
busy: record.busy,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function sessionsDir(home = os.homedir()) {
|
|
65
|
+
return path.join(home, ".whalecode", "sessions");
|
|
66
|
+
}
|
|
67
|
+
function isPidAlive(pid) {
|
|
68
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
69
|
+
return false;
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return err?.code === "EPERM";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function isSessionLive(record, now = Date.now(), staleMs = exports.BRIDGE_STALE_MS) {
|
|
79
|
+
if (!isPidAlive(record.pid))
|
|
80
|
+
return false;
|
|
81
|
+
const beat = record.heartbeatAt || record.startedAt;
|
|
82
|
+
return now - beat <= staleMs;
|
|
83
|
+
}
|
|
84
|
+
function workspaceContains(folders, target) {
|
|
85
|
+
if (!target)
|
|
86
|
+
return false;
|
|
87
|
+
const resolved = path.resolve(target);
|
|
88
|
+
return folders.some((folder) => {
|
|
89
|
+
const root = path.resolve(folder);
|
|
90
|
+
return resolved === root || resolved.startsWith(root + path.sep);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function pickSession(sessions, opts) {
|
|
94
|
+
if (opts.sessionId) {
|
|
95
|
+
const hit = sessions.find((s) => s.sessionId === opts.sessionId);
|
|
96
|
+
if (!hit) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: `没有匹配的 WhaleCode 窗口:sessionId=${opts.sessionId}`,
|
|
100
|
+
candidates: sessions,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, session: hit };
|
|
104
|
+
}
|
|
105
|
+
const workspace = opts.workspace?.trim();
|
|
106
|
+
const matched = workspace
|
|
107
|
+
? sessions.filter((s) => workspaceContains(s.workspaceFolders, workspace))
|
|
108
|
+
: sessions;
|
|
109
|
+
if (matched.length === 1)
|
|
110
|
+
return { ok: true, session: matched[0] };
|
|
111
|
+
if (matched.length === 0) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: workspace
|
|
115
|
+
? `没有打开的 WhaleCode 窗口包含工作区:${workspace}`
|
|
116
|
+
: "没有打开的 WhaleCode 窗口",
|
|
117
|
+
candidates: sessions,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: `匹配到 ${matched.length} 个 WhaleCode 窗口,请传入 sessionId`,
|
|
123
|
+
candidates: matched,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function readSessionFile(file) {
|
|
127
|
+
try {
|
|
128
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
129
|
+
if (!raw?.sessionId || !raw.port || !raw.token)
|
|
130
|
+
return undefined;
|
|
131
|
+
return raw;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function listLiveSessions(home = os.homedir(), opts) {
|
|
138
|
+
const dir = sessionsDir(home);
|
|
139
|
+
let names;
|
|
140
|
+
try {
|
|
141
|
+
names = fs.readdirSync(dir);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const now = opts?.now ?? Date.now();
|
|
147
|
+
const live = [];
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
if (!name.endsWith(".json"))
|
|
150
|
+
continue;
|
|
151
|
+
const file = path.join(dir, name);
|
|
152
|
+
const rec = readSessionFile(file);
|
|
153
|
+
if (!rec)
|
|
154
|
+
continue;
|
|
155
|
+
if (isSessionLive(rec, now)) {
|
|
156
|
+
live.push(rec);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (opts?.prune) {
|
|
160
|
+
try {
|
|
161
|
+
fs.unlinkSync(file);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* ignore */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return live;
|
|
169
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startMcpStdio = startMcpStdio;
|
|
4
|
+
const registry_1 = require("./registry");
|
|
5
|
+
const client_1 = require("./client");
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60_000;
|
|
7
|
+
const POLL_MS = 500;
|
|
8
|
+
const tools = [
|
|
9
|
+
{
|
|
10
|
+
name: "list_sessions",
|
|
11
|
+
description: "List live WhaleCode IDE windows (workspace, sessionId, busy). Use sessionId to target a specific window.",
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {},
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "run_agent",
|
|
20
|
+
description: "Run WhaleCode in an open IDE window with the given mode and chat text. Blocks until the run finishes, is cancelled, errors, or times out. Route by workspace path or sessionId; if several windows match, pass sessionId.",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
text: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Chat prompt to send to WhaleCode.",
|
|
27
|
+
},
|
|
28
|
+
mode: {
|
|
29
|
+
type: "string",
|
|
30
|
+
enum: [...registry_1.BRIDGE_MODES],
|
|
31
|
+
description: "Agent mode. Defaults to the window's current mode.",
|
|
32
|
+
},
|
|
33
|
+
model: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Model id or name (e.g. qwen3-5-397B, kimi-26, deepseek-v4-flash). Omit to use the first available model in WhaleCode's 推荐 list.",
|
|
36
|
+
},
|
|
37
|
+
workspace: {
|
|
38
|
+
type: "string",
|
|
39
|
+
description: "Workspace path used to pick a window. Defaults to the MCP process cwd.",
|
|
40
|
+
},
|
|
41
|
+
sessionId: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Explicit WhaleCode window id from list_sessions.",
|
|
44
|
+
},
|
|
45
|
+
conversationId: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "Existing conversation to continue. Omit to use current.",
|
|
48
|
+
},
|
|
49
|
+
newChat: {
|
|
50
|
+
type: "boolean",
|
|
51
|
+
description: "Start a new conversation before sending.",
|
|
52
|
+
},
|
|
53
|
+
timeoutMs: {
|
|
54
|
+
type: "number",
|
|
55
|
+
description: `How long to wait for a terminal status (default ${DEFAULT_TIMEOUT_MS}).`,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
required: ["text"],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "cancel_run",
|
|
64
|
+
description: "Cancel an in-flight WhaleCode run started via run_agent.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
runId: { type: "string", description: "Run id returned by run_agent." },
|
|
69
|
+
sessionId: {
|
|
70
|
+
type: "string",
|
|
71
|
+
description: "Optional window id if the run is not in this MCP process.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
required: ["runId"],
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
const runSessions = new Map();
|
|
80
|
+
function sleep(ms) {
|
|
81
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
82
|
+
}
|
|
83
|
+
function textResult(data, isError = false) {
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: "text",
|
|
88
|
+
text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
isError,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function writeMessage(msg) {
|
|
95
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
96
|
+
}
|
|
97
|
+
async function callTool(name, args) {
|
|
98
|
+
if (name === "list_sessions") {
|
|
99
|
+
const sessions = (0, registry_1.listLiveSessions)(undefined, { prune: true });
|
|
100
|
+
const enriched = await Promise.all(sessions.map(async (s) => {
|
|
101
|
+
try {
|
|
102
|
+
const health = await (0, client_1.getHealth)(s);
|
|
103
|
+
return {
|
|
104
|
+
...(0, registry_1.toPublicSession)(s),
|
|
105
|
+
busy: health.busy,
|
|
106
|
+
conversationId: health.conversationId,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return (0, registry_1.toPublicSession)(s);
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
return textResult({ sessions: enriched });
|
|
114
|
+
}
|
|
115
|
+
if (name === "run_agent") {
|
|
116
|
+
const text = typeof args.text === "string" ? args.text.trim() : "";
|
|
117
|
+
if (!text)
|
|
118
|
+
return textResult({ error: "text is required" }, true);
|
|
119
|
+
const mode = typeof args.mode === "string" ? args.mode : undefined;
|
|
120
|
+
if (mode &&
|
|
121
|
+
!registry_1.BRIDGE_MODES.includes(mode)) {
|
|
122
|
+
return textResult({ error: `invalid mode: ${mode}` }, true);
|
|
123
|
+
}
|
|
124
|
+
const sessions = (0, registry_1.listLiveSessions)(undefined, { prune: true });
|
|
125
|
+
const workspace = typeof args.workspace === "string" && args.workspace.trim()
|
|
126
|
+
? args.workspace.trim()
|
|
127
|
+
: process.cwd();
|
|
128
|
+
const picked = (0, registry_1.pickSession)(sessions, {
|
|
129
|
+
sessionId: typeof args.sessionId === "string" ? args.sessionId : undefined,
|
|
130
|
+
workspace,
|
|
131
|
+
});
|
|
132
|
+
if (!picked.ok) {
|
|
133
|
+
return textResult({
|
|
134
|
+
error: picked.error,
|
|
135
|
+
sessions: picked.candidates.map(registry_1.toPublicSession),
|
|
136
|
+
}, true);
|
|
137
|
+
}
|
|
138
|
+
const accepted = await (0, client_1.startRun)(picked.session, {
|
|
139
|
+
text,
|
|
140
|
+
mode,
|
|
141
|
+
model: typeof args.model === "string" && args.model.trim()
|
|
142
|
+
? args.model.trim()
|
|
143
|
+
: undefined,
|
|
144
|
+
conversationId: typeof args.conversationId === "string"
|
|
145
|
+
? args.conversationId
|
|
146
|
+
: undefined,
|
|
147
|
+
newChat: args.newChat === true,
|
|
148
|
+
});
|
|
149
|
+
runSessions.set(accepted.runId, picked.session);
|
|
150
|
+
const timeoutMs = typeof args.timeoutMs === "number" && args.timeoutMs > 0
|
|
151
|
+
? args.timeoutMs
|
|
152
|
+
: DEFAULT_TIMEOUT_MS;
|
|
153
|
+
const deadline = Date.now() + timeoutMs;
|
|
154
|
+
let last;
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
last = await (0, client_1.getRun)(picked.session, accepted.runId);
|
|
157
|
+
if (last.status !== "running") {
|
|
158
|
+
return textResult({
|
|
159
|
+
runId: last.runId,
|
|
160
|
+
conversationId: last.conversationId,
|
|
161
|
+
sessionId: picked.session.sessionId,
|
|
162
|
+
status: last.status,
|
|
163
|
+
model: last.model ?? accepted.model,
|
|
164
|
+
assistantText: last.assistantText,
|
|
165
|
+
toolSummary: last.toolSummary,
|
|
166
|
+
error: last.error,
|
|
167
|
+
needsApproval: last.needsApproval,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
await sleep(POLL_MS);
|
|
171
|
+
}
|
|
172
|
+
const pending = last?.pendingApproval || last?.needsApproval;
|
|
173
|
+
return textResult({
|
|
174
|
+
runId: accepted.runId,
|
|
175
|
+
conversationId: accepted.conversationId,
|
|
176
|
+
sessionId: picked.session.sessionId,
|
|
177
|
+
status: "timeout",
|
|
178
|
+
model: last?.model ?? accepted.model,
|
|
179
|
+
assistantText: last?.assistantText ?? "",
|
|
180
|
+
toolSummary: last?.toolSummary ?? [],
|
|
181
|
+
error: last?.error,
|
|
182
|
+
needsApproval: !!pending,
|
|
183
|
+
pendingApproval: !!last?.pendingApproval,
|
|
184
|
+
}, true);
|
|
185
|
+
}
|
|
186
|
+
if (name === "cancel_run") {
|
|
187
|
+
const runId = typeof args.runId === "string" ? args.runId.trim() : "";
|
|
188
|
+
if (!runId)
|
|
189
|
+
return textResult({ error: "runId is required" }, true);
|
|
190
|
+
const sessions = (0, registry_1.listLiveSessions)(undefined, { prune: true });
|
|
191
|
+
const known = typeof args.sessionId === "string"
|
|
192
|
+
? sessions.find((s) => s.sessionId === args.sessionId) ||
|
|
193
|
+
runSessions.get(runId)
|
|
194
|
+
: runSessions.get(runId);
|
|
195
|
+
const session = await (0, client_1.findRunSession)(sessions, runId, known);
|
|
196
|
+
if (!session) {
|
|
197
|
+
return textResult({ error: `run not found: ${runId}` }, true);
|
|
198
|
+
}
|
|
199
|
+
const result = await (0, client_1.cancelRun)(session, runId);
|
|
200
|
+
return textResult({ ...result, sessionId: session.sessionId });
|
|
201
|
+
}
|
|
202
|
+
return textResult({ error: `unknown tool: ${name}` }, true);
|
|
203
|
+
}
|
|
204
|
+
async function handle(req) {
|
|
205
|
+
const id = req.id ?? null;
|
|
206
|
+
const method = req.method || "";
|
|
207
|
+
if (method === "initialize") {
|
|
208
|
+
writeMessage({
|
|
209
|
+
jsonrpc: "2.0",
|
|
210
|
+
id,
|
|
211
|
+
result: {
|
|
212
|
+
protocolVersion: "2024-11-05",
|
|
213
|
+
capabilities: { tools: {} },
|
|
214
|
+
serverInfo: { name: "whalecode", version: "0.0.1" },
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (method === "notifications/initialized" || method.startsWith("notifications/")) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (method === "ping") {
|
|
223
|
+
writeMessage({ jsonrpc: "2.0", id, result: {} });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (method === "tools/list") {
|
|
227
|
+
writeMessage({ jsonrpc: "2.0", id, result: { tools } });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (method === "tools/call") {
|
|
231
|
+
const params = (req.params || {});
|
|
232
|
+
try {
|
|
233
|
+
const result = await callTool(params.name || "", params.arguments || {});
|
|
234
|
+
writeMessage({ jsonrpc: "2.0", id, result });
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
writeMessage({
|
|
238
|
+
jsonrpc: "2.0",
|
|
239
|
+
id,
|
|
240
|
+
result: textResult({ error: err instanceof Error ? err.message : String(err) }, true),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (id === null || id === undefined)
|
|
246
|
+
return;
|
|
247
|
+
writeMessage({
|
|
248
|
+
jsonrpc: "2.0",
|
|
249
|
+
id,
|
|
250
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function startMcpStdio() {
|
|
254
|
+
process.stdin.setEncoding("utf8");
|
|
255
|
+
let buffer = "";
|
|
256
|
+
process.stdin.on("data", (chunk) => {
|
|
257
|
+
buffer += chunk;
|
|
258
|
+
let nl;
|
|
259
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
260
|
+
const line = buffer.slice(0, nl).trim();
|
|
261
|
+
buffer = buffer.slice(nl + 1);
|
|
262
|
+
if (!line)
|
|
263
|
+
continue;
|
|
264
|
+
let req;
|
|
265
|
+
try {
|
|
266
|
+
req = JSON.parse(line);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
process.stderr.write(`whalecode-mcp: invalid JSON: ${line}\n`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
void handle(req).catch((err) => {
|
|
273
|
+
process.stderr.write(`whalecode-mcp: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
process.stdin.on("end", () => process.exit(0));
|
|
278
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "whalecode-mcp",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "WhaleCode MCP 服务器,将 MCP 主机(Cursor 等)的工具调用转发到本机已打开的 WhaleCode IDE 窗口。通过 WhaleCode 扩展的 localhost bridge 通信,支持按工作区路径或 sessionId 路由会话,提供多种 Agent 模式和模型选择,同步轮询运行状态直至完成、取消、出错或超时。",
|
|
5
|
+
"bin": {
|
|
6
|
+
"whalecode-mcp": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -p ./tsconfig.json",
|
|
14
|
+
"check-types": "tsc --noEmit -p ./tsconfig.json"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|