@spaceflow/core 0.15.0 → 0.17.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/dist/index.js +172 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli-runtime/extension-loader.ts +20 -0
- package/src/commands/mcp/mcp.service.ts +148 -6
- package/src/extension-system/types.ts +25 -0
- package/src/locales/en/mcp.json +1 -0
- package/src/locales/zh-cn/mcp.json +1 -0
- package/src/shared/git-provider/adapters/gitea.adapter.spec.ts +2 -2
- package/src/shared/git-provider/adapters/gitea.adapter.ts +14 -1
package/package.json
CHANGED
|
@@ -81,4 +81,24 @@ export class ExtensionLoader {
|
|
|
81
81
|
}
|
|
82
82
|
return result;
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 获取所有 MCP 资源
|
|
87
|
+
* 返回扩展中定义的 resources 字段
|
|
88
|
+
*/
|
|
89
|
+
getResources(): Array<{
|
|
90
|
+
extensionName: string;
|
|
91
|
+
resources: NonNullable<ExtensionDefinition["resources"]>;
|
|
92
|
+
}> {
|
|
93
|
+
const result: Array<{
|
|
94
|
+
extensionName: string;
|
|
95
|
+
resources: NonNullable<ExtensionDefinition["resources"]>;
|
|
96
|
+
}> = [];
|
|
97
|
+
for (const ext of this.extensions.values()) {
|
|
98
|
+
if (ext.resources && ext.resources.length > 0) {
|
|
99
|
+
result.push({ extensionName: ext.name, resources: ext.resources });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
84
104
|
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { t } from "@spaceflow/core";
|
|
2
|
-
import type { VerboseLevel } from "@spaceflow/core";
|
|
2
|
+
import type { VerboseLevel, McpResourceDefinition, SpaceflowContext } from "@spaceflow/core";
|
|
3
3
|
import { shouldLog, type McpToolMetadata } from "@spaceflow/core";
|
|
4
|
+
import { readConfigSync } from "@spaceflow/shared";
|
|
4
5
|
import type { ExtensionLoader } from "../../cli-runtime/extension-loader";
|
|
5
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { spawn } from "child_process";
|
|
9
10
|
|
|
11
|
+
/** 内部 resource 收集结构 */
|
|
12
|
+
interface CollectedResource {
|
|
13
|
+
resource: McpResourceDefinition;
|
|
14
|
+
ctx: SpaceflowContext;
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
export class McpService {
|
|
11
18
|
constructor(private readonly extensionLoader: ExtensionLoader) {}
|
|
12
19
|
|
|
@@ -57,16 +64,27 @@ export class McpService {
|
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
|
|
67
|
+
// 收集扩展 resources + 内置 resources
|
|
68
|
+
const allResources = this.collectResources(verbose);
|
|
69
|
+
|
|
70
|
+
if (allTools.length === 0 && allResources.length === 0) {
|
|
61
71
|
console.error(t("mcp:noToolsFound"));
|
|
62
72
|
console.error(t("mcp:noToolsHint"));
|
|
63
73
|
process.exit(1);
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
if (shouldLog(verbose, 1)) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
if (allTools.length > 0) {
|
|
78
|
+
console.error(t("mcp:toolsFound", { count: allTools.length }));
|
|
79
|
+
for (const { tool } of allTools) {
|
|
80
|
+
console.error(` - ${tool.name}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (allResources.length > 0) {
|
|
84
|
+
console.error(t("mcp:resourcesFound", { count: allResources.length }));
|
|
85
|
+
for (const { resource } of allResources) {
|
|
86
|
+
console.error(` - ${resource.name} (${resource.uri})`);
|
|
87
|
+
}
|
|
70
88
|
}
|
|
71
89
|
}
|
|
72
90
|
|
|
@@ -78,7 +96,93 @@ export class McpService {
|
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
// 被 MCP 客户端通过管道调用,正常启动 stdio server
|
|
81
|
-
await this.runServer(allTools, verbose);
|
|
99
|
+
await this.runServer(allTools, allResources, verbose);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 收集所有 MCP 资源(扩展 + 内置)
|
|
104
|
+
*/
|
|
105
|
+
private collectResources(verbose?: VerboseLevel): CollectedResource[] {
|
|
106
|
+
const ctx = this.extensionLoader.getContext();
|
|
107
|
+
const allResources: CollectedResource[] = [];
|
|
108
|
+
|
|
109
|
+
// 1. 收集扩展声明的 resources
|
|
110
|
+
const extensionResources = this.extensionLoader.getResources();
|
|
111
|
+
for (const { extensionName, resources } of extensionResources) {
|
|
112
|
+
if (shouldLog(verbose, 2)) {
|
|
113
|
+
console.error(` 扩展 ${extensionName} 提供 ${resources.length} 个 MCP 资源`);
|
|
114
|
+
}
|
|
115
|
+
for (const resource of resources) {
|
|
116
|
+
allResources.push({ resource, ctx });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. 内置资源:项目配置(过滤敏感字段)
|
|
121
|
+
allResources.push({
|
|
122
|
+
resource: {
|
|
123
|
+
name: "spaceflow-config",
|
|
124
|
+
uri: "spaceflow://config",
|
|
125
|
+
title: "Spaceflow Configuration",
|
|
126
|
+
description: "当前项目的 Spaceflow 配置(已过滤敏感字段)",
|
|
127
|
+
mimeType: "application/json",
|
|
128
|
+
handler: async (_uri, ctx) => {
|
|
129
|
+
const config = readConfigSync(ctx.cwd);
|
|
130
|
+
return JSON.stringify(this.sanitizeConfig(config), null, 2);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
ctx,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 3. 内置资源:扩展列表
|
|
137
|
+
allResources.push({
|
|
138
|
+
resource: {
|
|
139
|
+
name: "spaceflow-extensions",
|
|
140
|
+
uri: "spaceflow://extensions",
|
|
141
|
+
title: "Installed Extensions",
|
|
142
|
+
description: "当前项目已安装的 Spaceflow 扩展及其工具/资源",
|
|
143
|
+
mimeType: "application/json",
|
|
144
|
+
handler: async () => {
|
|
145
|
+
const extensions = this.extensionLoader.getExtensions();
|
|
146
|
+
const summary = extensions.map((ext) => ({
|
|
147
|
+
name: ext.name,
|
|
148
|
+
version: ext.version,
|
|
149
|
+
description: ext.description,
|
|
150
|
+
commands: ext.commands.map((c) => c.name),
|
|
151
|
+
tools: (ext.tools || []).map((t) => ({ name: t.name, description: t.description })),
|
|
152
|
+
resources: (ext.resources || []).map((r) => ({
|
|
153
|
+
name: r.name,
|
|
154
|
+
uri: r.uri,
|
|
155
|
+
description: r.description,
|
|
156
|
+
})),
|
|
157
|
+
}));
|
|
158
|
+
return JSON.stringify(summary, null, 2);
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
ctx,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return allResources;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 过滤配置中的敏感字段
|
|
169
|
+
*/
|
|
170
|
+
private sanitizeConfig(config: Record<string, any>): Record<string, any> {
|
|
171
|
+
const sensitiveKeys = ["token", "apiKey", "appSecret", "authToken", "apikey", "secret"];
|
|
172
|
+
const sanitize = (obj: any): any => {
|
|
173
|
+
if (obj === null || obj === undefined || typeof obj !== "object") return obj;
|
|
174
|
+
if (Array.isArray(obj)) return obj.map(sanitize);
|
|
175
|
+
const result: Record<string, any> = {};
|
|
176
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
177
|
+
if (sensitiveKeys.some((sk) => key.toLowerCase().includes(sk.toLowerCase()))) {
|
|
178
|
+
result[key] = value ? "***" : "";
|
|
179
|
+
} else {
|
|
180
|
+
result[key] = sanitize(value);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
};
|
|
185
|
+
return sanitize(config);
|
|
82
186
|
}
|
|
83
187
|
|
|
84
188
|
/**
|
|
@@ -86,6 +190,7 @@ export class McpService {
|
|
|
86
190
|
*/
|
|
87
191
|
private async runServer(
|
|
88
192
|
allTools: Array<{ tool: McpToolMetadata; handler: any; ctx: any }>,
|
|
193
|
+
allResources: CollectedResource[],
|
|
89
194
|
verbose?: VerboseLevel,
|
|
90
195
|
): Promise<void> {
|
|
91
196
|
const server = new McpServer({ name: "spaceflow", version: "1.0.0" });
|
|
@@ -126,6 +231,43 @@ export class McpService {
|
|
|
126
231
|
);
|
|
127
232
|
}
|
|
128
233
|
|
|
234
|
+
// 注册所有资源(使用 v2 API: server.registerResource)
|
|
235
|
+
for (const { resource, ctx } of allResources) {
|
|
236
|
+
server.registerResource(
|
|
237
|
+
resource.name,
|
|
238
|
+
resource.uri,
|
|
239
|
+
{
|
|
240
|
+
title: resource.title,
|
|
241
|
+
description: resource.description,
|
|
242
|
+
mimeType: resource.mimeType || "application/json",
|
|
243
|
+
},
|
|
244
|
+
async (uri) => {
|
|
245
|
+
try {
|
|
246
|
+
const text = await resource.handler(uri.href, ctx);
|
|
247
|
+
return {
|
|
248
|
+
contents: [
|
|
249
|
+
{
|
|
250
|
+
uri: uri.href,
|
|
251
|
+
mimeType: resource.mimeType || "application/json",
|
|
252
|
+
text,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
};
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return {
|
|
258
|
+
contents: [
|
|
259
|
+
{
|
|
260
|
+
uri: uri.href,
|
|
261
|
+
mimeType: "text/plain",
|
|
262
|
+
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
129
271
|
// 启动 stdio 传输
|
|
130
272
|
const transport = new StdioServerTransport();
|
|
131
273
|
await server.connect(transport);
|
|
@@ -136,6 +136,29 @@ export interface ServiceDefinition {
|
|
|
136
136
|
factory: (ctx: SpaceflowContext) => unknown;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* MCP 资源定义
|
|
141
|
+
*/
|
|
142
|
+
export interface McpResourceDefinition {
|
|
143
|
+
/** 资源名称(唯一标识符) */
|
|
144
|
+
name: string;
|
|
145
|
+
/** 资源 URI(固定 URI,如 "config://spaceflow") */
|
|
146
|
+
uri: string;
|
|
147
|
+
/** 资源标题(人类可读) */
|
|
148
|
+
title?: string;
|
|
149
|
+
/** 资源描述 */
|
|
150
|
+
description?: string;
|
|
151
|
+
/** MIME 类型(默认 application/json) */
|
|
152
|
+
mimeType?: string;
|
|
153
|
+
/**
|
|
154
|
+
* 资源读取处理函数
|
|
155
|
+
* @param uri 请求的 URI
|
|
156
|
+
* @param ctx Spaceflow 上下文
|
|
157
|
+
* @returns 资源内容(字符串)
|
|
158
|
+
*/
|
|
159
|
+
handler: (uri: string, ctx: SpaceflowContext) => Promise<string>;
|
|
160
|
+
}
|
|
161
|
+
|
|
139
162
|
/**
|
|
140
163
|
* MCP 工具定义
|
|
141
164
|
*/
|
|
@@ -174,6 +197,8 @@ export interface ExtensionDefinition {
|
|
|
174
197
|
commands: CommandDefinition[];
|
|
175
198
|
/** MCP 工具列表 */
|
|
176
199
|
tools?: McpToolDefinition[];
|
|
200
|
+
/** MCP 资源列表 */
|
|
201
|
+
resources?: McpResourceDefinition[];
|
|
177
202
|
/** 服务定义列表 */
|
|
178
203
|
services?: ServiceDefinition[];
|
|
179
204
|
/**
|
package/src/locales/en/mcp.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"noToolsFound": "❌ No MCP tools found",
|
|
17
17
|
"noToolsHint": " Hint: Make sure you have installed MCP-enabled extensions that export mcpService or getMcpTools\n If the working directory is incorrect, set the environment variable in your MCP config:\n \"env\": { \"SPACEFLOW_CWD\": \"/path/to/your/project\" }",
|
|
18
18
|
"toolsFound": "✅ Found {{count}} MCP tools",
|
|
19
|
+
"resourcesFound": "📦 Found {{count}} MCP resources",
|
|
19
20
|
"ttyHint": "💡 MCP Server must be started by an MCP client (e.g. Cursor, Claude Desktop) via pipe.\n Add the following config to your MCP client:\n\n \"spaceflow\": {\n \"command\": \"npx\",\n \"args\": [\"@spaceflow/cli\", \"mcp\"],\n \"env\": { \"SPACEFLOW_CWD\": \"/path/to/your/project\" }\n }",
|
|
20
21
|
"serverStarted": "🚀 MCP Server started with {{count}} tools"
|
|
21
22
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"noToolsFound": "❌ 没有找到任何 MCP 工具",
|
|
17
17
|
"noToolsHint": " 提示: 确保已安装支持 MCP 的扩展,并导出 mcpService 或 getMcpTools\n 如果工作目录不正确,请在 MCP 配置中设置环境变量:\n \"env\": { \"SPACEFLOW_CWD\": \"/path/to/your/project\" }",
|
|
18
18
|
"toolsFound": "✅ 共发现 {{count}} 个 MCP 工具",
|
|
19
|
+
"resourcesFound": "📦 共发现 {{count}} 个 MCP 资源",
|
|
19
20
|
"ttyHint": "💡 MCP Server 需要由 MCP 客户端(如 Cursor、Claude Desktop)通过管道启动。\n 请将以下配置添加到你的 MCP 客户端中:\n\n \"spaceflow\": {\n \"command\": \"npx\",\n \"args\": [\"@spaceflow/cli\", \"mcp\"],\n \"env\": { \"SPACEFLOW_CWD\": \"/path/to/your/project\" }\n }",
|
|
20
21
|
"serverStarted": "🚀 MCP Server 已启动,共 {{count}} 个工具"
|
|
21
22
|
}
|
|
@@ -383,11 +383,11 @@ describe("GiteaAdapter", () => {
|
|
|
383
383
|
});
|
|
384
384
|
|
|
385
385
|
describe("listPullReviews", () => {
|
|
386
|
-
it("应请求正确的 URL", async () => {
|
|
386
|
+
it("应请求正确的 URL(带分页参数)", async () => {
|
|
387
387
|
fetchSpy.mockResolvedValue(mockResponse([]));
|
|
388
388
|
await adapter.listPullReviews("owner", "repo", 42);
|
|
389
389
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
390
|
-
"https://gitea.example.com/api/v1/repos/owner/repo/pulls/42/reviews",
|
|
390
|
+
"https://gitea.example.com/api/v1/repos/owner/repo/pulls/42/reviews?page=1&limit=50",
|
|
391
391
|
expect.anything(),
|
|
392
392
|
);
|
|
393
393
|
});
|
|
@@ -438,7 +438,20 @@ export class GiteaAdapter implements GitProvider {
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
async listPullReviews(owner: string, repo: string, index: number): Promise<PullReview[]> {
|
|
441
|
-
|
|
441
|
+
const allReviews: PullReview[] = [];
|
|
442
|
+
let page = 1;
|
|
443
|
+
const limit = 50;
|
|
444
|
+
while (true) {
|
|
445
|
+
const reviews = await this.request<PullReview[]>(
|
|
446
|
+
"GET",
|
|
447
|
+
`/repos/${owner}/${repo}/pulls/${index}/reviews?page=${page}&limit=${limit}`,
|
|
448
|
+
);
|
|
449
|
+
if (!reviews || reviews.length === 0) break;
|
|
450
|
+
allReviews.push(...reviews);
|
|
451
|
+
if (reviews.length < limit) break;
|
|
452
|
+
page++;
|
|
453
|
+
}
|
|
454
|
+
return allReviews;
|
|
442
455
|
}
|
|
443
456
|
|
|
444
457
|
async updatePullReview(
|