create-windy 0.2.23 → 0.2.24

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.
Files changed (49) hide show
  1. package/dist/cli.js +21 -1
  2. package/package.json +1 -1
  3. package/template/.agents/skills/windy-business-module/SKILL.md +43 -0
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +2 -0
  6. package/template/apps/server/package.json +3 -1
  7. package/template/apps/server/src/agent/data-scope-routes.test.ts +20 -20
  8. package/template/apps/server/src/agent/remote-runtime.test.ts +59 -0
  9. package/template/apps/server/src/agent/remote-runtime.ts +22 -4
  10. package/template/apps/server/src/agent/routes.test.ts +16 -8
  11. package/template/apps/server/src/agent/routes.ts +103 -169
  12. package/template/apps/server/src/agent/run-facade.test.ts +30 -0
  13. package/template/apps/server/src/agent/runtime-bootstrap.ts +3 -3
  14. package/template/apps/server/src/agent/tool-host.test.ts +61 -8
  15. package/template/apps/server/src/agent/tool-host.ts +17 -73
  16. package/template/apps/server/src/agent/tool-registry.test.ts +117 -0
  17. package/template/apps/server/src/agent/tool-registry.ts +318 -0
  18. package/template/apps/server/src/http-app.test.ts +87 -0
  19. package/template/apps/server/src/http-app.ts +43 -25
  20. package/template/apps/server/src/index.ts +14 -2
  21. package/template/apps/server/src/license/runtime-service.test.ts +6 -3
  22. package/template/apps/server/src/mcp/adapter.test.ts +195 -0
  23. package/template/apps/server/src/mcp/adapter.ts +204 -0
  24. package/template/apps/server/src/mcp/routes.ts +21 -0
  25. package/template/apps/server/src/module-host/tool-handlers.ts +6 -5
  26. package/template/apps/server/src/observability/http-observability.test.ts +21 -0
  27. package/template/apps/server/src/observability/http-observability.ts +1 -0
  28. package/template/apps/server/src/observability/metrics.ts +5 -2
  29. package/template/docs/operations/observability.md +9 -1
  30. package/template/docs/platform/agent-runtime.md +15 -3
  31. package/template/docs/platform/agent-tools.md +88 -28
  32. package/template/packages/modules/index.ts +1 -0
  33. package/template/packages/modules/src/ai-operation-composition.test.ts +30 -0
  34. package/template/packages/modules/src/ai-operation-composition.ts +13 -0
  35. package/template/packages/modules/src/composition.test.ts +42 -0
  36. package/template/packages/modules/src/composition.ts +3 -0
  37. package/template/packages/modules/src/manifest.ts +20 -1
  38. package/template/packages/modules/src/system-agent-tools.ts +53 -1
  39. package/template/packages/modules/src/system-api-permissions.ts +5 -0
  40. package/template/packages/modules/src/system-module-catalog.ts +1 -0
  41. package/template/packages/modules/src/system-modules.test.ts +32 -0
  42. package/template/packages/modules/src/system-modules.ts +9 -1
  43. package/template/packages/modules/src/tool-composition.ts +86 -0
  44. package/template/packages/server-sdk/index.ts +1 -0
  45. package/template/packages/server-sdk/package.json +1 -0
  46. package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -7
  47. package/template/packages/server-sdk/src/tool-registration.ts +6 -7
  48. package/template/packages/shared/index.ts +1 -0
  49. 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/routes.js";
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: (query, context) =>
18
- registration.execute(query, {
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
  });
@@ -27,6 +27,7 @@ export class HttpObservability {
27
27
  input.observation,
28
28
  statusCode,
29
29
  );
30
+ if (durationMs === undefined) return;
30
31
  this.logger.info(
31
32
  {
32
33
  event: "http.request.completed",
@@ -34,8 +34,11 @@ export class RuntimeMetrics {
34
34
  };
35
35
  }
36
36
 
37
- finishRequest(observation: RequestObservation, statusCode: number): number {
38
- if (observation.finished) return 0;
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 指标格式、Server/License 请求完成日志字段、Web 到 Server 的标识透传,均有 Bun 单元或边界测试;工作区 typecheck 和 Oxlint 已通过。Dockerfile 已声明 readiness `HEALTHCHECK`。
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.21 或更早版本升级
129
+ ## 从 create-windy 0.2.23 或更早版本升级
118
130
 
119
131
  先检查计划,再应用连续 recipe:
120
132
 
121
133
  ```bash
122
- bunx --bun create-windy@0.2.22 update --check
123
- bunx --bun create-windy@0.2.22 update
134
+ bunx --bun create-windy@0.2.24 update --check
135
+ bunx --bun create-windy@0.2.24 update
124
136
  bun install
125
137
  ```
126
138
 
@@ -1,44 +1,104 @@
1
1
  # Agent Tool 注册与执行
2
2
 
3
- ## 当前状态
3
+ ## 能力契约
4
4
 
5
- Server 端已提供 Agent Tool 的最小闭环:
5
+ Agent Tool 是业务模块向平台 Agent Runtime 和后续 MCP Adapter 暴露业务能力的唯一
6
+ 声明。业务模块不得分别维护 Agent、MCP 或 OpenAPI Tool Key。
6
7
 
7
- - 工具定义来自模块 manifest `tools` 字段。
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
- OpenAPI 文档由 `@elysiajs/swagger` 提供,随 Server 启动自动暴露。
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
- - `system.users.list`:查询用户。
24
- - `system.features.list`:查询功能开关。
25
- - `system.audit.list`:查询审计日志。
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
- token 来自内存运行时上下文,只用于本地闭环验证,不是生产登录体系。
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
- - 工具执行复用系统资源 Repository;未配置 `DATABASE_URL` 时使用内存仓储,配置后使用 Drizzle 仓储。
40
- - 工具参数当前只支持基础分页参数。
41
- - 审计事件写入内存 sink;配置 `DATABASE_URL` 后会通过有界重试写入 PostgreSQL `audit_logs`,持续失败会增加计数并输出告警,审计查询优先读取该表。详细降级语义见 [平台审计可靠写入](./audit-reliability.md)
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