create-windy 0.2.23 → 0.2.25
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/cli.js +100 -2
- package/package.json +1 -1
- package/template/.agents/skills/windy-business-module/SKILL.md +43 -0
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +2 -0
- package/template/apps/server/package.json +3 -1
- package/template/apps/server/src/agent/data-scope-routes.test.ts +20 -20
- package/template/apps/server/src/agent/remote-runtime.test.ts +59 -0
- package/template/apps/server/src/agent/remote-runtime.ts +22 -4
- package/template/apps/server/src/agent/routes.test.ts +16 -8
- package/template/apps/server/src/agent/routes.ts +103 -169
- package/template/apps/server/src/agent/run-facade.test.ts +30 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +3 -3
- package/template/apps/server/src/agent/tool-host.test.ts +61 -8
- package/template/apps/server/src/agent/tool-host.ts +17 -73
- package/template/apps/server/src/agent/tool-registry.test.ts +117 -0
- package/template/apps/server/src/agent/tool-registry.ts +318 -0
- package/template/apps/server/src/http-app.test.ts +87 -0
- package/template/apps/server/src/http-app.ts +43 -25
- package/template/apps/server/src/index.ts +14 -2
- package/template/apps/server/src/license/runtime-service.test.ts +6 -3
- package/template/apps/server/src/mcp/adapter.test.ts +195 -0
- package/template/apps/server/src/mcp/adapter.ts +204 -0
- package/template/apps/server/src/mcp/routes.ts +21 -0
- package/template/apps/server/src/module-host/tool-handlers.ts +6 -5
- package/template/apps/server/src/observability/http-observability.test.ts +21 -0
- package/template/apps/server/src/observability/http-observability.ts +1 -0
- package/template/apps/server/src/observability/metrics.ts +5 -2
- package/template/docs/operations/observability.md +9 -1
- package/template/docs/platform/agent-runtime.md +15 -3
- package/template/docs/platform/agent-tools.md +88 -28
- package/template/packages/modules/index.ts +1 -0
- package/template/packages/modules/src/ai-operation-composition.test.ts +30 -0
- package/template/packages/modules/src/ai-operation-composition.ts +13 -0
- package/template/packages/modules/src/composition.test.ts +42 -0
- package/template/packages/modules/src/composition.ts +3 -0
- package/template/packages/modules/src/manifest.ts +20 -1
- package/template/packages/modules/src/system-agent-tools.ts +53 -1
- package/template/packages/modules/src/system-api-permissions.ts +5 -0
- package/template/packages/modules/src/system-module-catalog.ts +1 -0
- package/template/packages/modules/src/system-modules.test.ts +32 -0
- package/template/packages/modules/src/system-modules.ts +9 -1
- package/template/packages/modules/src/tool-composition.ts +86 -0
- package/template/packages/server-sdk/index.ts +1 -0
- package/template/packages/server-sdk/package.json +1 -0
- package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -7
- package/template/packages/server-sdk/src/tool-registration.ts +6 -7
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/src/json.ts +8 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import {
|
|
5
|
+
createFoundationSnapshot,
|
|
6
|
+
loadFoundationModules,
|
|
7
|
+
} from "../foundation.js";
|
|
8
|
+
import { createServerRuntime } from "../runtime.js";
|
|
9
|
+
import { createSystemRepositories } from "../system/seed.js";
|
|
10
|
+
import { createSystemToolHandlers } from "../agent/routes.js";
|
|
11
|
+
import { GovernedToolRegistry } from "../agent/tool-registry.js";
|
|
12
|
+
import { PlatformMcpAdapter } from "./adapter.js";
|
|
13
|
+
|
|
14
|
+
const clients: Client[] = [];
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await Promise.all(clients.splice(0).map((client) => client.close()));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("Platform MCP Adapter", () => {
|
|
21
|
+
test("只发现当前 Actor 获准的 query Tool 并返回结构化结果", async () => {
|
|
22
|
+
const fixture = createFixture();
|
|
23
|
+
const client = await connectClient(fixture, "limited-token");
|
|
24
|
+
const listed = await client.listTools();
|
|
25
|
+
|
|
26
|
+
expect(listed.tools.map(({ name }) => name)).toEqual([
|
|
27
|
+
"system.features.list",
|
|
28
|
+
]);
|
|
29
|
+
expect(listed.tools[0]).toMatchObject({
|
|
30
|
+
annotations: {
|
|
31
|
+
readOnlyHint: true,
|
|
32
|
+
destructiveHint: false,
|
|
33
|
+
},
|
|
34
|
+
inputSchema: { type: "object" },
|
|
35
|
+
outputSchema: { type: "object" },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const called = await client.callTool({
|
|
39
|
+
name: "system.features.list",
|
|
40
|
+
arguments: { pageSize: 2 },
|
|
41
|
+
});
|
|
42
|
+
expect(called.isError).not.toBe(true);
|
|
43
|
+
expect(called.structuredContent).toMatchObject({
|
|
44
|
+
page: 1,
|
|
45
|
+
pageSize: 2,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("直接调用未授权或未知 Tool 使用相同脱敏错误", async () => {
|
|
50
|
+
const fixture = createFixture();
|
|
51
|
+
const client = await connectClient(fixture, "limited-token");
|
|
52
|
+
|
|
53
|
+
const denied = await client.callTool({
|
|
54
|
+
name: "system.users.list",
|
|
55
|
+
arguments: {},
|
|
56
|
+
});
|
|
57
|
+
const missing = await client.callTool({
|
|
58
|
+
name: "system.unknown.list",
|
|
59
|
+
arguments: {},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(denied).toMatchObject({
|
|
63
|
+
isError: true,
|
|
64
|
+
content: [{ type: "text", text: "工具不可用或无权限。" }],
|
|
65
|
+
});
|
|
66
|
+
expect(missing).toEqual(denied);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("未认证和跨源浏览器请求在协议解析前拒绝", async () => {
|
|
70
|
+
const fixture = createFixture();
|
|
71
|
+
const anonymousRequest = mcpRequest();
|
|
72
|
+
const anonymousContext =
|
|
73
|
+
await fixture.runtime.createGuardContext(anonymousRequest);
|
|
74
|
+
const anonymous = await fixture.adapter.handle(
|
|
75
|
+
anonymousRequest,
|
|
76
|
+
anonymousContext,
|
|
77
|
+
);
|
|
78
|
+
expect(anonymous.status).toBe(401);
|
|
79
|
+
expect(anonymous.headers.get("www-authenticate")).toBe("Bearer");
|
|
80
|
+
|
|
81
|
+
const missingEntryRequest = mcpRequest("target-only-token");
|
|
82
|
+
const missingEntryContext =
|
|
83
|
+
await fixture.runtime.createGuardContext(missingEntryRequest);
|
|
84
|
+
const missingEntry = await fixture.adapter.handle(
|
|
85
|
+
missingEntryRequest,
|
|
86
|
+
missingEntryContext,
|
|
87
|
+
);
|
|
88
|
+
expect(missingEntry.status).toBe(403);
|
|
89
|
+
expect(
|
|
90
|
+
fixture.runtime.auditSink
|
|
91
|
+
.list()
|
|
92
|
+
.some(({ action }) => action === "security.denied"),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
|
|
95
|
+
const crossOriginRequest = mcpRequest("limited-token", "https://evil.test");
|
|
96
|
+
const crossOriginContext =
|
|
97
|
+
await fixture.runtime.createGuardContext(crossOriginRequest);
|
|
98
|
+
const denied = await fixture.adapter.handle(
|
|
99
|
+
crossOriginRequest,
|
|
100
|
+
crossOriginContext,
|
|
101
|
+
);
|
|
102
|
+
expect(denied.status).toBe(403);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function createFixture() {
|
|
107
|
+
const modules = loadFoundationModules();
|
|
108
|
+
const runtime = createServerRuntime(
|
|
109
|
+
{},
|
|
110
|
+
{
|
|
111
|
+
actorResolver: {
|
|
112
|
+
resolve: (request) => {
|
|
113
|
+
const authorization = request.headers.get("authorization");
|
|
114
|
+
if (
|
|
115
|
+
authorization !== "Bearer limited-token" &&
|
|
116
|
+
authorization !== "Bearer target-only-token"
|
|
117
|
+
) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
id: "actor-limited",
|
|
122
|
+
type: "user",
|
|
123
|
+
name: "受限用户",
|
|
124
|
+
roleCodes: ["member"],
|
|
125
|
+
permissionKeys:
|
|
126
|
+
authorization === "Bearer limited-token"
|
|
127
|
+
? ["system.agent.read", "system.feature.read"]
|
|
128
|
+
: ["system.feature.read"],
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
featureCatalog: modules.flatMap(({ features }) => features),
|
|
133
|
+
licenseResolver: { resolve: () => ({ status: "valid" }) },
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
const handlers = createSystemToolHandlers(
|
|
137
|
+
createSystemRepositories(createFoundationSnapshot()),
|
|
138
|
+
runtime,
|
|
139
|
+
);
|
|
140
|
+
const registry = new GovernedToolRegistry(modules, handlers, runtime);
|
|
141
|
+
return {
|
|
142
|
+
adapter: new PlatformMcpAdapter(
|
|
143
|
+
registry,
|
|
144
|
+
runtime,
|
|
145
|
+
modules.flatMap(({ features }) => features),
|
|
146
|
+
),
|
|
147
|
+
runtime,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function connectClient(
|
|
152
|
+
fixture: ReturnType<typeof createFixture>,
|
|
153
|
+
token: string,
|
|
154
|
+
): Promise<Client> {
|
|
155
|
+
const transport = new StreamableHTTPClientTransport(
|
|
156
|
+
new URL("http://localhost/api/mcp"),
|
|
157
|
+
{
|
|
158
|
+
requestInit: {
|
|
159
|
+
headers: { authorization: `Bearer ${token}` },
|
|
160
|
+
},
|
|
161
|
+
fetch: async (input, init) => {
|
|
162
|
+
const request = new Request(input, init);
|
|
163
|
+
const context = await fixture.runtime.createGuardContext(request);
|
|
164
|
+
return fixture.adapter.handle(request, context);
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
const client = new Client({ name: "windy-test", version: "0.1.0" });
|
|
169
|
+
clients.push(client);
|
|
170
|
+
await client.connect(transport);
|
|
171
|
+
return client;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function mcpRequest(token?: string, origin?: string): Request {
|
|
175
|
+
const headers = new Headers({
|
|
176
|
+
accept: "application/json, text/event-stream",
|
|
177
|
+
"content-type": "application/json",
|
|
178
|
+
});
|
|
179
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
180
|
+
if (origin) headers.set("origin", origin);
|
|
181
|
+
return new Request("http://localhost/api/mcp", {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers,
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
jsonrpc: "2.0",
|
|
186
|
+
id: 1,
|
|
187
|
+
method: "initialize",
|
|
188
|
+
params: {
|
|
189
|
+
protocolVersion: "2025-11-25",
|
|
190
|
+
capabilities: {},
|
|
191
|
+
clientInfo: { name: "windy-test", version: "0.1.0" },
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
type CallToolResult,
|
|
7
|
+
type Tool,
|
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import type {
|
|
10
|
+
ModuleToolInput,
|
|
11
|
+
ModuleToolResult,
|
|
12
|
+
} from "@southwind-ai/server-sdk";
|
|
13
|
+
import type { FeatureFlagDefinition } from "@southwind-ai/shared";
|
|
14
|
+
import { evaluateAccessGuards } from "../access-guards.js";
|
|
15
|
+
import type { RequestGuardContext } from "../guards.js";
|
|
16
|
+
import { findFeature } from "../route-guards.js";
|
|
17
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
18
|
+
import {
|
|
19
|
+
GovernedToolRegistry,
|
|
20
|
+
ToolExecutionError,
|
|
21
|
+
type RegisteredTool,
|
|
22
|
+
} from "../agent/tool-registry.js";
|
|
23
|
+
|
|
24
|
+
export class PlatformMcpAdapter {
|
|
25
|
+
readonly #feature: FeatureFlagDefinition;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly tools: GovernedToolRegistry,
|
|
29
|
+
private readonly runtime: ReturnType<typeof createServerRuntime>,
|
|
30
|
+
features: FeatureFlagDefinition[],
|
|
31
|
+
) {
|
|
32
|
+
const feature = findFeature(features, "system.agent");
|
|
33
|
+
if (!feature) throw new Error("MCP Adapter 缺少 system.agent Feature");
|
|
34
|
+
this.#feature = feature;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async handle(
|
|
38
|
+
request: Request,
|
|
39
|
+
context: RequestGuardContext,
|
|
40
|
+
parsedBody?: unknown,
|
|
41
|
+
): Promise<Response> {
|
|
42
|
+
const rejected = rejectRequest(request, context);
|
|
43
|
+
if (rejected) return rejected;
|
|
44
|
+
const access = await evaluateAccessGuards(this.runtime, context, {
|
|
45
|
+
permission: { permissionKey: "system.agent.read" },
|
|
46
|
+
feature: this.#feature,
|
|
47
|
+
});
|
|
48
|
+
if (!access.decision.allowed) {
|
|
49
|
+
return Response.json(
|
|
50
|
+
{
|
|
51
|
+
error: "FORBIDDEN",
|
|
52
|
+
reason: access.decision.reason,
|
|
53
|
+
},
|
|
54
|
+
{ status: 403 },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const server = this.createServer(context);
|
|
59
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
60
|
+
enableJsonResponse: true,
|
|
61
|
+
sessionIdGenerator: undefined,
|
|
62
|
+
});
|
|
63
|
+
await server.connect(transport);
|
|
64
|
+
try {
|
|
65
|
+
return await transport.handleRequest(request, { parsedBody });
|
|
66
|
+
} finally {
|
|
67
|
+
await server.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private createServer(context: RequestGuardContext): Server {
|
|
72
|
+
const server = new Server(
|
|
73
|
+
{ name: "windy-platform", version: "0.1.0" },
|
|
74
|
+
{
|
|
75
|
+
capabilities: { tools: {} },
|
|
76
|
+
instructions:
|
|
77
|
+
"仅调用当前账号可见的只读业务工具;权限和数据范围由 Windy 平台逐次校验。",
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
82
|
+
tools: await this.allowedQueryTools(context),
|
|
83
|
+
}));
|
|
84
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) =>
|
|
85
|
+
this.callTool(
|
|
86
|
+
request.params.name,
|
|
87
|
+
toToolInput(request.params.arguments ?? {}),
|
|
88
|
+
context,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
return server;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async allowedQueryTools(
|
|
95
|
+
context: RequestGuardContext,
|
|
96
|
+
): Promise<Tool[]> {
|
|
97
|
+
const tools = await Promise.all(
|
|
98
|
+
this.tools
|
|
99
|
+
.definitions()
|
|
100
|
+
.filter(({ operation }) => operation === "query")
|
|
101
|
+
.map(async (tool) =>
|
|
102
|
+
(await this.tools.allowed(tool.key, context))
|
|
103
|
+
? toMcpTool(tool)
|
|
104
|
+
: undefined,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
return tools.filter((tool): tool is Tool => tool !== undefined);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async callTool(
|
|
111
|
+
key: string,
|
|
112
|
+
input: ModuleToolInput,
|
|
113
|
+
context: RequestGuardContext,
|
|
114
|
+
): Promise<CallToolResult> {
|
|
115
|
+
const definition = this.tools
|
|
116
|
+
.definitions()
|
|
117
|
+
.find((tool) => tool.key === key && tool.operation === "query");
|
|
118
|
+
if (!definition) return toolError("工具不可用或无权限。");
|
|
119
|
+
try {
|
|
120
|
+
const result = await this.tools.execute(key, input, context);
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
123
|
+
structuredContent: toStructuredContent(result),
|
|
124
|
+
};
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (!(error instanceof ToolExecutionError)) throw error;
|
|
127
|
+
if (error.code === "TOOL_NOT_FOUND" || error.code === "TOOL_FORBIDDEN") {
|
|
128
|
+
return toolError("工具不可用或无权限。");
|
|
129
|
+
}
|
|
130
|
+
if (error.code === "TOOL_INPUT_INVALID") {
|
|
131
|
+
return toolError("工具输入不符合契约。");
|
|
132
|
+
}
|
|
133
|
+
if (error.code === "TOOL_TIMEOUT") {
|
|
134
|
+
return toolError("工具执行超时。");
|
|
135
|
+
}
|
|
136
|
+
return toolError("工具执行失败。");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toMcpTool(tool: RegisteredTool): Tool {
|
|
142
|
+
return {
|
|
143
|
+
name: tool.key,
|
|
144
|
+
title: tool.label,
|
|
145
|
+
description: tool.description,
|
|
146
|
+
inputSchema: toMcpSchema(tool.inputSchema),
|
|
147
|
+
outputSchema: toMcpSchema(tool.outputSchema),
|
|
148
|
+
annotations: {
|
|
149
|
+
title: tool.label,
|
|
150
|
+
readOnlyHint: true,
|
|
151
|
+
destructiveHint: false,
|
|
152
|
+
idempotentHint: true,
|
|
153
|
+
openWorldHint: false,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function toMcpSchema(
|
|
159
|
+
schema: RegisteredTool["inputSchema"],
|
|
160
|
+
): Tool["inputSchema"] {
|
|
161
|
+
if (schema.type !== "object") {
|
|
162
|
+
throw new Error("MCP Tool Schema 顶层类型必须是 object");
|
|
163
|
+
}
|
|
164
|
+
return schema as Tool["inputSchema"];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function toToolInput(
|
|
168
|
+
value: Readonly<Record<string, unknown>>,
|
|
169
|
+
): ModuleToolInput {
|
|
170
|
+
return JSON.parse(JSON.stringify(value)) as ModuleToolInput;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toStructuredContent(
|
|
174
|
+
result: ModuleToolResult,
|
|
175
|
+
): Record<string, unknown> {
|
|
176
|
+
return JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toolError(message: string): CallToolResult {
|
|
180
|
+
return {
|
|
181
|
+
content: [{ type: "text", text: message }],
|
|
182
|
+
isError: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function rejectRequest(
|
|
187
|
+
request: Request,
|
|
188
|
+
context: RequestGuardContext,
|
|
189
|
+
): Response | undefined {
|
|
190
|
+
if (context.actor.type === "anonymous") {
|
|
191
|
+
return Response.json(
|
|
192
|
+
{ error: "UNAUTHENTICATED" },
|
|
193
|
+
{
|
|
194
|
+
status: 401,
|
|
195
|
+
headers: { "www-authenticate": "Bearer" },
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const origin = request.headers.get("origin");
|
|
200
|
+
if (origin && origin !== new URL(request.url).origin) {
|
|
201
|
+
return Response.json({ error: "ORIGIN_DENIED" }, { status: 403 });
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RouteApp } from "../system/routes.js";
|
|
2
|
+
import { PlatformMcpAdapter } from "./adapter.js";
|
|
3
|
+
|
|
4
|
+
interface McpRouteContext {
|
|
5
|
+
readonly request: Request;
|
|
6
|
+
readonly body?: unknown;
|
|
7
|
+
readonly guardContext: Parameters<PlatformMcpAdapter["handle"]>[1];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function registerMcpRoutes(
|
|
11
|
+
app: RouteApp,
|
|
12
|
+
adapter: PlatformMcpAdapter,
|
|
13
|
+
): void {
|
|
14
|
+
const handle = (rawContext: unknown) => {
|
|
15
|
+
const context = rawContext as McpRouteContext;
|
|
16
|
+
return adapter.handle(context.request, context.guardContext, context.body);
|
|
17
|
+
};
|
|
18
|
+
app.get("/mcp", handle);
|
|
19
|
+
app.post("/mcp", handle);
|
|
20
|
+
app.delete("/mcp", handle);
|
|
21
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModuleToolHandlers } from "../agent/
|
|
1
|
+
import type { ModuleToolHandlers } from "../agent/tool-registry.js";
|
|
2
2
|
import type { InitializedModuleHosts } from "./initialize.js";
|
|
3
3
|
import { toModuleActor } from "./request-context.js";
|
|
4
4
|
|
|
@@ -14,10 +14,11 @@ export function createModuleToolHandlerMap(
|
|
|
14
14
|
registration.toolKey,
|
|
15
15
|
{
|
|
16
16
|
scopeMode: registration.scopeMode,
|
|
17
|
-
execute: (
|
|
18
|
-
registration.execute(
|
|
19
|
-
actor: toModuleActor(context.actor),
|
|
20
|
-
requestId: context.requestId,
|
|
17
|
+
execute: (input, context) =>
|
|
18
|
+
registration.execute(input, {
|
|
19
|
+
actor: toModuleActor(context.guardContext.actor),
|
|
20
|
+
requestId: context.guardContext.requestId,
|
|
21
|
+
signal: context.signal,
|
|
21
22
|
}),
|
|
22
23
|
},
|
|
23
24
|
]),
|
|
@@ -50,4 +50,25 @@ describe("HTTP observability", () => {
|
|
|
50
50
|
expect(responseStatus(new Response(null, { status: 503 }), 200)).toBe(503);
|
|
51
51
|
expect(responseStatus({}, 201)).toBe(201);
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
test("同一次 observation 重复完成时不重复记录日志", () => {
|
|
55
|
+
const records: Record<string, unknown>[] = [];
|
|
56
|
+
const observer = new HttpObservability(new RuntimeMetrics(), {
|
|
57
|
+
info(fields) {
|
|
58
|
+
records.push(fields);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const observation = observer.begin("GET");
|
|
62
|
+
const input = {
|
|
63
|
+
request: new Request("https://example.test/api/failure"),
|
|
64
|
+
guardContext: context,
|
|
65
|
+
observation,
|
|
66
|
+
responseValue: new Response(null, { status: 500 }),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
observer.complete(input);
|
|
70
|
+
observer.complete(input);
|
|
71
|
+
|
|
72
|
+
expect(records).toHaveLength(1);
|
|
73
|
+
});
|
|
53
74
|
});
|
|
@@ -34,8 +34,11 @@ export class RuntimeMetrics {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
finishRequest(
|
|
38
|
-
|
|
37
|
+
finishRequest(
|
|
38
|
+
observation: RequestObservation,
|
|
39
|
+
statusCode: number,
|
|
40
|
+
): number | undefined {
|
|
41
|
+
if (observation.finished) return;
|
|
39
42
|
observation.finished = true;
|
|
40
43
|
this.activeRequests = Math.max(0, this.activeRequests - 1);
|
|
41
44
|
const durationMs =
|
|
@@ -33,6 +33,11 @@ Web、Server、License 和 Signing 接受 `x-request-id`。只保留 1 至 128
|
|
|
33
33
|
|
|
34
34
|
请求完成日志禁止记录完整 URL、query、请求或响应 body、Cookie、Authorization、Secret、连接串和原始异常。需要增加字段时,必须先判断其基数和敏感性,并补充防泄漏测试。生产日志采集器应按 JSON 字段解析,按 `requestId` 关联,不依赖自由文本。
|
|
35
35
|
|
|
36
|
+
请求观测在全局 `onRequest` 建立,在响应完成时原子取出并结束。未匹配路由、Guard
|
|
37
|
+
拒绝和 handler 异常与正常请求遵循同一生命周期;即使框架同时触发 error 与
|
|
38
|
+
after-response 路径,同一个请求也只能扣减一次活跃计数并写一条完成日志。业务路由
|
|
39
|
+
不能假设只有匹配成功的请求才存在观测状态。
|
|
40
|
+
|
|
36
41
|
## Server 指标
|
|
37
42
|
|
|
38
43
|
`GET /api/metrics` 输出 Prometheus text format,并设置 `Cache-Control: no-store`。当前指标包括:
|
|
@@ -45,6 +50,9 @@ Web、Server、License 和 Signing 接受 `x-request-id`。只保留 1 至 128
|
|
|
45
50
|
|
|
46
51
|
## 当前验证状态
|
|
47
52
|
|
|
48
|
-
已验证:共享关联标识校验、四个服务的 liveness/readiness 分离、Server 指标格式、
|
|
53
|
+
已验证:共享关联标识校验、四个服务的 liveness/readiness 分离、Server 指标格式、
|
|
54
|
+
Server/License 请求完成日志字段、Web 到 Server 的标识透传,以及 Server 未匹配路由、
|
|
55
|
+
异常路由只完成一次观测且不会终止进程,均有 Bun 单元或边界测试;工作区 typecheck 和
|
|
56
|
+
Oxlint 已通过。Dockerfile 已声明 readiness `HEALTHCHECK`。
|
|
49
57
|
|
|
50
58
|
尚未由本页证明:生产 Compose、新环境部署、备份恢复、升级回滚、原生 Linux amd64 runner、告警规则、长稳运行、多实例和大容量。这些项必须由后续生产恢复演练和目标环境记录单独闭环;Docker Desktop 或 QEMU 上的 amd64 运行不能写成原生 Linux amd64 已认证。
|
|
@@ -62,6 +62,18 @@ Tool handler 仍通过 `registerToolHandlers` 注册。模型提出 Tool Call
|
|
|
62
62
|
Tool 请求不能携带权限列表、License、Scope 或“已授权”标志。客户端或模型提供的这些
|
|
63
63
|
字段会被拒绝,未知 Tool 也不会形成注册表探测通道。
|
|
64
64
|
|
|
65
|
+
## 跨进程 Limits 契约
|
|
66
|
+
|
|
67
|
+
Manifest 的 Operation limits 同时包含输入、执行时间和输出上限。Southwind AI Server
|
|
68
|
+
在调用远端 Agent Server 前先执行 `maxInputBytes` 校验;跨进程请求只逐字段发送 Agent
|
|
69
|
+
Server 负责执行的 `maxDurationSeconds` 和 `maxOutputBytes`。禁止通过展开 Manifest
|
|
70
|
+
对象透传 limits:TypeScript 的结构类型不会在运行时移除额外字段,而 Agent Server
|
|
71
|
+
契约会以 `extra=forbid` 拒绝未知字段。
|
|
72
|
+
|
|
73
|
+
这个边界保证输入上限仍由持有原始业务输入的 Server 强制执行,同时避免 Python Runtime
|
|
74
|
+
被平台内部但不属于远端契约的字段阻断。新增 limits 字段时,必须分别决定执行归属,并在
|
|
75
|
+
真实 HTTP 序列化测试中确认请求体。
|
|
76
|
+
|
|
65
77
|
## 本地 Compose profile
|
|
66
78
|
|
|
67
79
|
选择 `system.agent` 的 create-windy 项目会生成 `agent` profile 和随机内部 Token。
|
|
@@ -114,13 +126,13 @@ SSE 事件包括 `output.delta`、`tool.requested`、`tool.completed`、usage,
|
|
|
114
126
|
`run.completed`、`run.failed` 或 `run.cancelled` 终态。断线后使用已确认 sequence 作为
|
|
115
127
|
cursor 恢复;不要重新创建 Run 或重复执行 Tool。
|
|
116
128
|
|
|
117
|
-
## 从 create-windy 0.2.
|
|
129
|
+
## 从 create-windy 0.2.24 或更早版本升级
|
|
118
130
|
|
|
119
131
|
先检查计划,再应用连续 recipe:
|
|
120
132
|
|
|
121
133
|
```bash
|
|
122
|
-
bunx --bun create-windy@0.2.
|
|
123
|
-
bunx --bun create-windy@0.2.
|
|
134
|
+
bunx --bun create-windy@0.2.25 update --check
|
|
135
|
+
bunx --bun create-windy@0.2.25 update
|
|
124
136
|
bun install
|
|
125
137
|
```
|
|
126
138
|
|
|
@@ -1,44 +1,104 @@
|
|
|
1
1
|
# Agent Tool 注册与执行
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 能力契约
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Agent Tool 是业务模块向平台 Agent Runtime 和后续 MCP Adapter 暴露业务能力的唯一
|
|
6
|
+
声明。业务模块不得分别维护 Agent、MCP 或 OpenAPI Tool Key。
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
- `GET /api/agent/tools` 返回已注册工具和当前请求是否有权限。
|
|
9
|
-
- `POST /api/agent/tools/:key/execute` 执行工具。
|
|
10
|
-
- 执行前使用 Permission Guard 校验 `permissionKey`。
|
|
11
|
-
- 权限拒绝记录 `security.denied` 审计事件。
|
|
12
|
-
- 执行成功记录 `agent.tool.execute` 审计事件。
|
|
13
|
-
- ModuleHost 通过 `registerAiOperationHandlers` 注册模型无关 Operation plan。
|
|
14
|
-
- Guarded Run facade 只把 Operation `allowedToolKeys` 中的定义交给模型。
|
|
15
|
-
- Agent Server 的 Tool Call 必须通过短时 Execution Grant 回到受限 Tool Host。
|
|
16
|
-
- Tool Host 使用宿主绑定的 Actor 与 Scope 重新执行 Operation/Tool Guard;客户端和模型
|
|
17
|
-
不能传权限、License、Scope 或授权结论。
|
|
8
|
+
每个 Tool 在 Module Manifest 中声明:
|
|
18
9
|
|
|
19
|
-
|
|
10
|
+
- 稳定 Key、中文名称和模型可理解的说明;
|
|
11
|
+
- 输入、输出 JSON Schema Key;
|
|
12
|
+
- `query`、`command` 或 `background` 操作类型;
|
|
13
|
+
- Feature、Permission、Audit Action 和 `platform/request` 数据范围模式;
|
|
14
|
+
- 数据分类、幂等策略、审批策略;
|
|
15
|
+
- 最大执行时间、输入字节数和输出字节数。
|
|
20
16
|
|
|
21
|
-
|
|
17
|
+
JSON Schema 通过同一 Manifest 的 `jsonSchemas` 注册。Schema Key 全局唯一,顶层必须是
|
|
18
|
+
`object`;Tool 引用未知 Schema、Schema 重复、限制非正整数、查询能力错误声明审批时,
|
|
19
|
+
Composition 在 Server 监听前失败。
|
|
22
20
|
|
|
23
|
-
- `
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
业务实现通过 `@southwind-ai/server-sdk` 的 `registerToolHandlers()` 注册类型化 JSON
|
|
22
|
+
Handler。Handler 的 `scopeMode` 必须与 Manifest 相同,并收到宿主提供的 Actor、
|
|
23
|
+
Request ID 和超时 AbortSignal,不能接收客户端提交的权限或数据范围。
|
|
24
|
+
|
|
25
|
+
## 统一执行 Registry
|
|
26
|
+
|
|
27
|
+
`GovernedToolRegistry` 是原生 Agent Tool Route、Agent Runtime Tool Host 和 MCP Adapter
|
|
28
|
+
共用的执行 Interface。每次调用按以下顺序执行:
|
|
29
|
+
|
|
30
|
+
1. 解析当前 Server Actor;
|
|
31
|
+
2. 重新执行 License、Feature 和 Permission Guard;
|
|
32
|
+
3. 对 `scopeMode=request` 验证 Server 构造的数据范围;
|
|
33
|
+
4. 按输入 Schema 应用默认值、类型归一化并移除未声明字段;
|
|
34
|
+
5. 检查输入大小并执行带超时信号的 Handler;
|
|
35
|
+
6. 检查输出大小和输出 Schema,输出漂移 fail closed;
|
|
36
|
+
7. 写入不含 Tool 原始参数和结果的受控审计摘要。
|
|
37
|
+
|
|
38
|
+
输入中出现 `actorId`、`permissionKeys`、`departmentId` 等未声明字段会在 Handler 前被
|
|
39
|
+
移除,不能成为授权事实。输出 Schema 不执行字段移除;Handler 返回未知字段或错误类型
|
|
40
|
+
会被视为实现漂移并拒绝。
|
|
41
|
+
|
|
42
|
+
## HTTP 接口
|
|
26
43
|
|
|
27
|
-
|
|
44
|
+
- `GET /api/agent/tools`:返回 Tool 描述、输入输出 Schema 和当前 Actor 的 `allowed`。
|
|
45
|
+
- `POST /api/agent/tools/:key/execute`:使用 JSON object body 执行 Tool。
|
|
28
46
|
|
|
29
|
-
|
|
47
|
+
基础分页 Tool 仍兼容旧 query 参数;新业务调用应使用 JSON body。
|
|
48
|
+
|
|
49
|
+
开发态可使用:
|
|
30
50
|
|
|
31
51
|
```text
|
|
32
52
|
Authorization: Bearer dev-admin-token
|
|
33
53
|
```
|
|
34
54
|
|
|
35
|
-
该
|
|
55
|
+
该 Token 只用于本地闭环验证,不是生产登录体系。
|
|
56
|
+
|
|
57
|
+
## 当前只读工具
|
|
58
|
+
|
|
59
|
+
- `system.users.list`:查询当前数据范围内的脱敏用户。
|
|
60
|
+
- `system.features.list`:查询功能开关。
|
|
61
|
+
- `system.audit.list`:查询审计事件。
|
|
62
|
+
|
|
63
|
+
三者共用 `system.pagination.input.v1` 与 `system.page.output.v1` Schema。
|
|
64
|
+
|
|
65
|
+
## Agent Runtime
|
|
66
|
+
|
|
67
|
+
ModuleHost 通过 `registerAiOperationHandlers` 注册模型无关 Operation plan。Guarded Run
|
|
68
|
+
facade 只允许 Operation `allowedToolKeys` 中的定义进入模型上下文。Agent Server 的 Tool
|
|
69
|
+
Call 必须通过短时 Execution Grant 回到受限 Tool Host,随后由统一 Registry 使用宿主绑定
|
|
70
|
+
的 Actor 与 Scope 再次授权。
|
|
71
|
+
|
|
72
|
+
Agent Runtime、环境变量、SSE 与 Compose profile 见
|
|
73
|
+
[Agent Runtime 接入与运行](./agent-runtime.md)。
|
|
74
|
+
|
|
75
|
+
## MCP Streamable HTTP
|
|
76
|
+
|
|
77
|
+
选择 `system.agent` 模块后,Server 在 `POST /api/mcp` 暴露官方 MCP TypeScript SDK
|
|
78
|
+
v1 的无状态 Streamable HTTP Endpoint;`GET` 和 `DELETE` 也注册在同一路径,由无状态
|
|
79
|
+
Transport 返回协议允许的状态。
|
|
80
|
+
|
|
81
|
+
接入要求:
|
|
82
|
+
|
|
83
|
+
- 请求必须使用现有平台 Bearer Token 或 Session Cookie 完成身份解析;
|
|
84
|
+
- 入口先经过 `system.agent` Feature 与 `system.agent.read` Permission Guard;
|
|
85
|
+
- 带浏览器 `Origin` 的请求必须与 Endpoint 同源;
|
|
86
|
+
- `tools/list` 只返回当前 Actor 获准的 `query` Tool;
|
|
87
|
+
- `tools/call` 不信任列表阶段结果,每次重新执行 Tool Guard 和数据范围校验;
|
|
88
|
+
- 成功结果同时提供 MCP 文本 `content` 与 `structuredContent`;
|
|
89
|
+
- 未授权与未知 Tool 返回同形脱敏错误,避免形成能力探测面。
|
|
90
|
+
|
|
91
|
+
示例 MCP Endpoint:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
http://localhost:9747/api/mcp
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
MCP 使用无状态模式,不保存授权快照或长期 Session。Feature、角色或数据范围变化会在下一次
|
|
98
|
+
调用立即生效。
|
|
36
99
|
|
|
37
|
-
##
|
|
100
|
+
## 后续边界
|
|
38
101
|
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
- Agent Runtime、环境变量、SSE 与 Compose profile 见
|
|
43
|
-
[Agent Runtime 接入与运行](./agent-runtime.md)。
|
|
44
|
-
- 后续应继续完善 Agent Tool 的结构化输入 schema、写操作幂等和执行结果追踪。
|
|
102
|
+
- `command/background` 在具备幂等键、审批和可追踪执行结果前不得通过 MCP 暴露。
|
|
103
|
+
- 完整 Tool 参数和结果默认不进入审计、日志或 Trace。
|
|
104
|
+
- OpenAPI 继续承担普通 HTTP 集成,不自动把所有 Route 机械转换为 Tool。
|