okengine 0.13.0 → 0.15.2
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/manifest.v1.schema.json +7 -1
- package/package.json +4 -5
- package/site/content/docs/ai/mcp.mdx +27 -2
- package/site/content/docs/elements/ai.mdx +59 -11
- package/site/content/docs/elements/channel.mdx +1 -0
- package/site/content/docs/elements/clock.mdx +30 -12
- package/site/content/docs/elements/flow.mdx +9 -3
- package/site/content/docs/elements/gate.mdx +25 -22
- package/site/content/docs/elements/signal.mdx +71 -25
- package/site/content/docs/elements/store.mdx +87 -28
- package/site/content/docs/elements/vault.mdx +1 -0
- package/site/content/docs/get-started/basic-usage.mdx +22 -13
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +2 -2
- package/site/content/docs/get-started/introduction.mdx +11 -3
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/project-structure.mdx +932 -0
- package/site/content/docs/reference/cli.md +13 -10
- package/site/content/docs/reference/client.mdx +25 -21
- package/site/content/docs/reference/fx.mdx +27 -22
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/bindings.ts +14 -5
- package/src/auth/cross-plane.ts +10 -3
- package/src/cli/ai-setup/ai-setup.test.ts +62 -0
- package/src/cli/ai-setup/apply.ts +32 -1
- package/src/cli/ai-setup/recommend.test.ts +25 -0
- package/src/cli/ai-setup/recommend.ts +8 -3
- package/src/cli/attach-host-console.test.ts +3 -2
- package/src/cli/attach-host-console.ts +3 -0
- package/src/cli/build.test.ts +4 -2
- package/src/cli/db-seed.ts +48 -15
- package/src/cli/db.test.ts +104 -0
- package/src/cli/db.ts +122 -10
- package/src/cli/dev-controls.test.ts +20 -0
- package/src/cli/dev-controls.ts +13 -0
- package/src/cli/dev.test.ts +15 -4
- package/src/cli/dev.ts +102 -29
- package/src/cli/ensure-drizzle-config.ts +2 -1
- package/src/cli/tui/DevLive.tsx +12 -2
- package/src/cli/tui/keys.test.ts +2 -1
- package/src/cli/tui/keys.ts +3 -2
- package/src/compiler/aot.test.ts +1 -1
- package/src/compiler/effects-infer.ts +58 -3
- package/src/compiler/extract.test.ts +458 -29
- package/src/compiler/extract.ts +387 -69
- package/src/compiler/flow-path.test.ts +96 -0
- package/src/compiler/flow-path.ts +173 -0
- package/src/compiler/generate-adopt.test.ts +112 -3
- package/src/compiler/generate-adopt.ts +280 -26
- package/src/compiler/response.ts +45 -1
- package/src/compiler/schema-from-ast.ts +609 -0
- package/src/console/server/ai.ts +5 -2
- package/src/console/server/bind.ts +2 -2
- package/src/console/server/console.test.ts +3 -0
- package/src/console/server/flows.ts +121 -40
- package/src/console/server/invoke-user-flow.test.ts +3 -1
- package/src/console/server/invoke-user-flow.ts +17 -2
- package/src/console/server/iso-at.test.ts +9 -0
- package/src/console/server/iso-at.ts +11 -0
- package/src/console/server/security-headers.ts +6 -3
- package/src/console/server/serve.ts +17 -2
- package/src/console/server/spa-proxy.test.ts +1 -0
- package/src/console/server/sql-catalog.ts +147 -7
- package/src/console/server/sql-rls.test.ts +16 -0
- package/src/console/server/state.ts +8 -0
- package/src/console/server/store.test.ts +7 -4
- package/src/console/server/store.ts +142 -58
- package/src/console/ui-next/dist/assets/agent-disclosure-C0X1fbWF.js +1 -0
- package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +1 -0
- package/src/console/ui-next/dist/assets/{call-pii-button-bqkxMrJH.js → call-pii-button-DEDCl_j3.js} +1 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +1 -0
- package/src/console/ui-next/dist/assets/{confirm-sheet-DDCRmG62.js → confirm-sheet-3ptDzXbz.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-oiRxPVsZ.js → duration-tone-sC3lGABz.js} +1 -1
- package/src/console/ui-next/dist/assets/{explorer-empty-HjTnVQoR.js → explorer-empty-BvYrygyO.js} +1 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CvDPvveV.js → highlighted-json-CS_O8L-r.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +1 -0
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +66 -0
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +2 -0
- package/src/console/ui-next/dist/assets/{link-DF7SZ9Ek.js → link-COeyggt-.js} +1 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +4 -0
- package/src/console/ui-next/dist/assets/react-D8E3mtu1.js +1 -0
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +18 -0
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +54 -0
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-Cl79j_ku.js → use-vault-list-BLbZhzyF.js} +1 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +2 -0
- package/src/console/ui-next/dist/index.html +6 -6
- package/src/console/ui-next/seed-invoke-host.ts +2 -0
- package/src/console/ui-next/src/client.ts +35 -7
- package/src/console/ui-next/src/components/motion/table/index.tsx +16 -0
- package/src/console/ui-next/src/components/motion/table/types.ts +4 -0
- package/src/console/ui-next/src/components/ui/kbd.tsx +2 -2
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
- package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
- package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/http-method.ts +19 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +29 -2
- package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +10 -3
- package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
- package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
- package/src/console/ui-next/src/features/store/detail/browse-section.tsx +45 -1
- package/src/console/ui-next/src/features/store/detail/resource-panel.tsx +78 -16
- package/src/console/ui-next/src/features/store/detail/store-row-detail-sheet.tsx +155 -14
- package/src/console/ui-next/src/features/store/explorer/store-tree.tsx +33 -133
- package/src/console/ui-next/src/features/store/files/file-preview.tsx +4 -1
- package/src/console/ui-next/src/features/store/grid/rls-policy-sheet.tsx +326 -165
- package/src/console/ui-next/src/features/store/grid/sql-insert-sheet.tsx +6 -0
- package/src/console/ui-next/src/features/store/grid/store-data-grid.tsx +50 -3
- package/src/console/ui-next/src/features/store/lib/files-meta.test.ts +3 -0
- package/src/console/ui-next/src/features/store/lib/files-meta.ts +12 -0
- package/src/console/ui-next/src/features/store/lib/query-defaults.test.ts +25 -0
- package/src/console/ui-next/src/features/store/lib/query-defaults.ts +85 -3
- package/src/console/ui-next/src/features/store/lib/query-gate.ts +30 -4
- package/src/console/ui-next/src/features/store/lib/rls-policy.test.ts +139 -0
- package/src/console/ui-next/src/features/store/lib/rls-policy.ts +223 -21
- package/src/console/ui-next/src/features/store/lib/schema-graph.ts +4 -0
- package/src/console/ui-next/src/features/store/lib/sql-catalog.test.ts +24 -0
- package/src/console/ui-next/src/features/store/lib/sql-catalog.ts +19 -0
- package/src/console/ui-next/src/features/store/lib/store-tree.test.ts +6 -0
- package/src/console/ui-next/src/features/store/lib/store-tree.ts +13 -0
- package/src/console/ui-next/src/features/store/query/query-console.tsx +48 -14
- package/src/console/ui-next/src/features/store/query/query-gate-menu.tsx +19 -20
- package/src/console/ui-next/src/features/store/query/query-gate-parts.tsx +2 -1
- package/src/console/ui-next/src/features/store/query/query-results.tsx +110 -83
- package/src/console/ui-next/src/features/store/schema/schema-table-node.tsx +17 -0
- package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +112 -72
- package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
- package/src/console/ui-next/src/features/units/lib/contract-input.test.ts +206 -1
- package/src/console/ui-next/src/features/units/lib/contract-input.ts +55 -0
- package/src/console/ui-next/src/features/vault/lib/types.ts +1 -1
- package/src/docker/compose-health.test.ts +27 -0
- package/src/docker/compose-health.ts +19 -3
- package/src/docker/docker.test.ts +4 -1
- package/src/docker/dockerfile.ts +1 -1
- package/src/docker/recipes/pgdog.ts +2 -0
- package/src/drivers/ai-anthropic.ts +5 -0
- package/src/drivers/ai-ollama.ts +49 -30
- package/src/drivers/ai-openai-compatible.ts +57 -46
- package/src/drivers/ai-providers.test.ts +3 -0
- package/src/drivers/bun-native-completeness.test.ts +7 -9
- package/src/drivers/clock-postgres.ts +13 -9
- package/src/drivers/instances-postgres.ts +4 -6
- package/src/drivers/journal-postgres.ts +13 -9
- package/src/drivers/pg-rls.test.ts +87 -0
- package/src/drivers/pg-rls.ts +233 -7
- package/src/drivers/pglite.ts +26 -3
- package/src/drivers/postgres.test.ts +23 -0
- package/src/drivers/postgres.ts +133 -9
- package/src/drivers/redis.ts +11 -4
- package/src/drivers/signal-redis.ts +24 -14
- package/src/drivers/signal-types.ts +2 -1
- package/src/drivers/types.ts +10 -0
- package/src/elements/ai/declare.ts +109 -0
- package/src/elements/ai/errors.test.ts +5 -1
- package/src/elements/ai/errors.ts +30 -2
- package/src/elements/ai/eval.ts +4 -6
- package/src/elements/ai/mcp-client.test.ts +206 -0
- package/src/elements/ai/mcp-client.ts +362 -0
- package/src/elements/ai/mcp-http.ts +159 -0
- package/src/elements/ai/mcp-mock.ts +134 -0
- package/src/elements/ai/mcp-protocol.ts +234 -0
- package/src/elements/ai/mcp-stdio.test.ts +50 -0
- package/src/elements/ai/mcp-stdio.ts +212 -0
- package/src/elements/ai/mcp-transport.ts +70 -0
- package/src/elements/ai/runtime.ts +159 -29
- package/src/elements/ai.test.ts +139 -0
- package/src/elements/ai.ts +16 -0
- package/src/elements/clock/declare.ts +26 -1
- package/src/elements/clock/health.test.ts +43 -0
- package/src/elements/clock/runtime.ts +55 -2
- package/src/elements/clock/schedule.ts +71 -142
- package/src/elements/clock.test.ts +9 -40
- package/src/elements/clock.ts +6 -1
- package/src/elements/gate/boot.ts +5 -5
- package/src/elements/gate/declare.ts +36 -4
- package/src/elements/gate/flatten.ts +1 -1
- package/src/elements/gate/runtime.ts +1 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/signal/declare.ts +2 -1
- package/src/elements/signal/runtime.ts +16 -1
- package/src/elements/signal.ts +1 -0
- package/src/elements/store/cache.test.ts +2 -0
- package/src/elements/store/cache.ts +3 -3
- package/src/elements/store/declare.ts +9 -6
- package/src/elements/store/emit-drizzle.ts +44 -8
- package/src/elements/store/resource.test.ts +1 -1
- package/src/elements/store/rls-identity.test.ts +95 -0
- package/src/elements/store/rls-identity.ts +116 -0
- package/src/elements/store/runtime.ts +4 -0
- package/src/elements/store/schema-decl.test.ts +37 -0
- package/src/elements/store/schema-decl.ts +161 -0
- package/src/elements/store/seed.ts +1 -1
- package/src/elements/store/sql-condition.test.ts +12 -0
- package/src/elements/store/sql-condition.ts +26 -2
- package/src/elements/store/sql-rls-isolation.test.ts +165 -0
- package/src/elements/store/sql-rls-stamp.test.ts +117 -0
- package/src/elements/store/sql-session.ts +81 -3
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/store.ts +14 -0
- package/src/elements/vault/builtin-adapter.ts +17 -0
- package/src/full.ts +2 -0
- package/src/http.ts +20 -3
- package/src/index.ts +14 -1
- package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
- package/src/kernel/adopt-routes.ts +30 -2
- package/src/kernel/app.ts +227 -77
- package/src/kernel/auto-cache.test.ts +8 -8
- package/src/kernel/auto-registry.test.ts +54 -9
- package/src/kernel/boot-bind/ai.ts +24 -0
- package/src/kernel/boot-bind/clock.ts +2 -0
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/boot.ts +5 -3
- package/src/kernel/effects-stamping.test.ts +3 -3
- package/src/kernel/element-registries.ts +15 -5
- package/src/kernel/errors.ts +24 -0
- package/src/kernel/flow-units.ts +64 -0
- package/src/kernel/flow.ts +29 -17
- package/src/kernel/fx-dead-letters.test.ts +77 -0
- package/src/kernel/fx.test.ts +22 -0
- package/src/kernel/fx.ts +135 -6
- package/src/kernel/horizontal-child.ts +4 -4
- package/src/kernel/http-path-pending.ts +20 -0
- package/src/kernel/http-resource.ts +4 -0
- package/src/kernel/http-stream.test.ts +174 -0
- package/src/kernel/index.ts +2 -0
- package/src/kernel/pipeline.test.ts +2 -2
- package/src/kernel/plugin-elements.test.ts +1 -1
- package/src/kernel/plugin-needs.test.ts +1 -1
- package/src/kernel/ready.test.ts +2 -2
- package/src/kernel/resource-mount.test.ts +14 -0
- package/src/kernel/run-duration.test.ts +2 -2
- package/src/kernel/stamp-http.test.ts +79 -0
- package/src/kernel/stamp-http.ts +75 -0
- package/src/kernel/triggers.ts +52 -27
- package/src/manifest/mcp-ref.ts +88 -0
- package/src/manifest/types.ts +41 -2
- package/src/manifest/validate.test.ts +20 -0
- package/src/mcp/docs-server.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/plugins/compression.test.ts +21 -0
- package/src/plugins/compression.ts +1 -0
- package/src/release/measure.ts +2 -2
- package/src/runtime/bun.ts +41 -4
- package/src/runtime/json-code-block.test.ts +2 -2
- package/src/runtime/serve.test.ts +4 -4
- package/src/term.test.ts +52 -4
- package/src/term.ts +125 -17
- package/src/test/reset-element-registries.ts +10 -3
- package/src/cli/ask-seed.test.ts +0 -96
- package/src/cli/ask-seed.ts +0 -82
- package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-DYb0xU8C.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
- package/src/console/ui-next/dist/assets/index-Bp-R7jtM.js +0 -66
- package/src/console/ui-next/dist/assets/index-D4Ldtj79.css +0 -2
- package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
- package/src/console/ui-next/dist/assets/query-gate-parts-1m8m1iNp.js +0 -2
- package/src/console/ui-next/dist/assets/react-B1ML8gxg.js +0 -1
- package/src/console/ui-next/dist/assets/replica-lag-C8_BRt2x.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-D-ata7jy.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-DO4IsVqv.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-CL_X0GCj.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-v3LXdYpr.js +0 -45
- package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
- package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-Ca-MvcmJ.js +0 -2
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP client protocol — 2026-07-28 happy path + dual-era fallback types.
|
|
3
|
+
*
|
|
4
|
+
* Reuses only the JSON-RPC envelope. Does not import the app MCP *server*
|
|
5
|
+
* dialect (`2024-11-05` initialize) as the client version.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Current MCP protocol version (stateless core). */
|
|
9
|
+
export const MCP_CLIENT_PROTOCOL_VERSION = "2026-07-28";
|
|
10
|
+
|
|
11
|
+
/** Legacy initialize dialect still in production. */
|
|
12
|
+
export const MCP_LEGACY_PROTOCOL_VERSION = "2024-11-05";
|
|
13
|
+
|
|
14
|
+
/** JSON-RPC request id. */
|
|
15
|
+
export type JsonRpcId = string | number;
|
|
16
|
+
|
|
17
|
+
/** Modern protocol era vs initialize handshake. */
|
|
18
|
+
export type McpProtocolEra = "modern" | "legacy";
|
|
19
|
+
|
|
20
|
+
/** JSON-RPC error object. */
|
|
21
|
+
export interface McpRpcError {
|
|
22
|
+
readonly code: number;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
readonly data?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Modern MCP error codes that are *not* a cue to `initialize`. */
|
|
28
|
+
export const McpModernErrorCode = {
|
|
29
|
+
headerMismatch: -32020,
|
|
30
|
+
missingRequiredClientCapability: -32021,
|
|
31
|
+
unsupportedProtocolVersion: -32022,
|
|
32
|
+
/** Resource not found — remapped to invalid params; not version negotiation. */
|
|
33
|
+
resourceNotFound: -32002,
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
/** Per-request `_meta` for the 2026-07-28 stateless core. */
|
|
37
|
+
export interface McpClientMeta {
|
|
38
|
+
readonly "io.modelcontextprotocol/protocolVersion": typeof MCP_CLIENT_PROTOCOL_VERSION;
|
|
39
|
+
readonly clientCapabilities: {
|
|
40
|
+
readonly extensions: Readonly<Record<string, never>>;
|
|
41
|
+
};
|
|
42
|
+
readonly clientInfo: {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly version: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Tool listed by `tools/list`. */
|
|
49
|
+
export interface McpListedTool {
|
|
50
|
+
readonly name: string;
|
|
51
|
+
readonly description?: string;
|
|
52
|
+
readonly inputSchema?: unknown;
|
|
53
|
+
readonly outputSchema?: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parsed JSON-RPC success or error. */
|
|
57
|
+
export type ParsedJsonRpcResponse =
|
|
58
|
+
| { readonly ok: true; readonly id: JsonRpcId | null; readonly result: unknown }
|
|
59
|
+
| { readonly ok: false; readonly id: JsonRpcId | null; readonly error: McpRpcError };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the required per-request `_meta` bag.
|
|
63
|
+
*
|
|
64
|
+
* @param version - Client package version
|
|
65
|
+
*/
|
|
66
|
+
export function mcpClientMeta(version: string): McpClientMeta {
|
|
67
|
+
return {
|
|
68
|
+
"io.modelcontextprotocol/protocolVersion": MCP_CLIENT_PROTOCOL_VERSION,
|
|
69
|
+
clientCapabilities: { extensions: {} },
|
|
70
|
+
clientInfo: { name: "okengine", version },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* True when a 400 JSON-RPC error is a modern protocol reject (do not initialize).
|
|
76
|
+
*
|
|
77
|
+
* @param code - JSON-RPC error code
|
|
78
|
+
*/
|
|
79
|
+
export function isModernProtocolReject(code: number): boolean {
|
|
80
|
+
return (
|
|
81
|
+
code === McpModernErrorCode.unsupportedProtocolVersion ||
|
|
82
|
+
code === McpModernErrorCode.headerMismatch ||
|
|
83
|
+
code === McpModernErrorCode.missingRequiredClientCapability
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a JSON-RPC 2.0 response object.
|
|
89
|
+
*
|
|
90
|
+
* @param body - Decoded JSON
|
|
91
|
+
*/
|
|
92
|
+
export function parseJsonRpcResponse(body: unknown): ParsedJsonRpcResponse | null {
|
|
93
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
|
|
94
|
+
const o = body as Record<string, unknown>;
|
|
95
|
+
if (o.jsonrpc !== "2.0") return null;
|
|
96
|
+
const id = o.id;
|
|
97
|
+
if (id !== null && id !== undefined && typeof id !== "string" && typeof id !== "number") {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const rid = (id ?? null) as JsonRpcId | null;
|
|
101
|
+
if ("error" in o) {
|
|
102
|
+
const err = o.error;
|
|
103
|
+
if (err === null || typeof err !== "object" || Array.isArray(err)) return null;
|
|
104
|
+
const e = err as Record<string, unknown>;
|
|
105
|
+
if (typeof e.code !== "number" || typeof e.message !== "string") return null;
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
id: rid,
|
|
109
|
+
error: {
|
|
110
|
+
code: e.code,
|
|
111
|
+
message: e.message,
|
|
112
|
+
...(e.data !== undefined ? { data: e.data } : {}),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (!("result" in o)) return null;
|
|
117
|
+
return { ok: true, id: rid, result: o.result };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse `tools/list` result.tools.
|
|
122
|
+
*
|
|
123
|
+
* @param result - RPC result
|
|
124
|
+
*/
|
|
125
|
+
export function parseToolsListResult(result: unknown): McpListedTool[] {
|
|
126
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) return [];
|
|
127
|
+
const tools = (result as Record<string, unknown>).tools;
|
|
128
|
+
if (!Array.isArray(tools)) return [];
|
|
129
|
+
const out: McpListedTool[] = [];
|
|
130
|
+
for (const raw of tools) {
|
|
131
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
132
|
+
const t = raw as Record<string, unknown>;
|
|
133
|
+
if (typeof t.name !== "string" || t.name.length === 0) continue;
|
|
134
|
+
out.push({
|
|
135
|
+
name: t.name,
|
|
136
|
+
...(typeof t.description === "string" ? { description: t.description } : {}),
|
|
137
|
+
...(t.inputSchema !== undefined ? { inputSchema: t.inputSchema } : {}),
|
|
138
|
+
...(t.outputSchema !== undefined ? { outputSchema: t.outputSchema } : {}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Interpret `tools/call` result. Missing `resultType` means `"complete"`.
|
|
146
|
+
*
|
|
147
|
+
* @param result - RPC result
|
|
148
|
+
*/
|
|
149
|
+
export function interpretToolsCallResult(result: unknown): unknown {
|
|
150
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) {
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
const o = result as Record<string, unknown>;
|
|
154
|
+
const resultType = o.resultType;
|
|
155
|
+
if (resultType === undefined || resultType === "complete") return result;
|
|
156
|
+
if (resultType === "input_required") {
|
|
157
|
+
throw new Error("ai.mcp: tool returned input_required (HITL is not supported)");
|
|
158
|
+
}
|
|
159
|
+
const label = typeof resultType === "string" ? resultType : JSON.stringify(resultType);
|
|
160
|
+
throw new Error(`ai.mcp: unrecognized resultType "${label}"`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Valid HTTP header token (RFC 9110).
|
|
165
|
+
*
|
|
166
|
+
* @param name - Candidate header name
|
|
167
|
+
*/
|
|
168
|
+
export function isHttpHeaderToken(name: string): boolean {
|
|
169
|
+
return name.length > 0 && /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/.test(name);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Collect `x-mcp-header` params → `Mcp-Param-*` headers.
|
|
174
|
+
* Returns null when a declared header name is invalid (caller drops the tool).
|
|
175
|
+
*
|
|
176
|
+
* @param inputSchema - Tool JSON schema
|
|
177
|
+
* @param args - Call arguments
|
|
178
|
+
*/
|
|
179
|
+
export function mcpParamHeaders(
|
|
180
|
+
inputSchema: unknown,
|
|
181
|
+
args: Record<string, unknown>,
|
|
182
|
+
): { readonly headers: Record<string, string>; readonly body: Record<string, unknown> } | null {
|
|
183
|
+
const props = schemaProperties(inputSchema);
|
|
184
|
+
if (!props) return { headers: {}, body: { ...args } };
|
|
185
|
+
const headers: Record<string, string> = {};
|
|
186
|
+
const body: Record<string, unknown> = { ...args };
|
|
187
|
+
for (const [key, schema] of Object.entries(props)) {
|
|
188
|
+
const header = headerNameFromSchema(key, schema);
|
|
189
|
+
if (header === undefined) continue;
|
|
190
|
+
if (header === null) return null;
|
|
191
|
+
const value = args[key];
|
|
192
|
+
if (value === undefined) continue;
|
|
193
|
+
headers[header] = typeof value === "string" ? value : JSON.stringify(value);
|
|
194
|
+
delete body[key];
|
|
195
|
+
}
|
|
196
|
+
return { headers, body };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* True when the tool's `x-mcp-header` annotations are all valid.
|
|
201
|
+
*
|
|
202
|
+
* @param inputSchema - Tool JSON schema
|
|
203
|
+
*/
|
|
204
|
+
export function mcpHeaderAnnotationsValid(inputSchema: unknown): boolean {
|
|
205
|
+
return mcpParamHeaders(inputSchema, {}) !== null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function schemaProperties(inputSchema: unknown): Record<string, unknown> | undefined {
|
|
209
|
+
if (inputSchema === null || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
const props = (inputSchema as Record<string, unknown>).properties;
|
|
213
|
+
if (props === null || typeof props !== "object" || Array.isArray(props)) return undefined;
|
|
214
|
+
return props as Record<string, unknown>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function headerNameFromSchema(key: string, schema: unknown): string | null | undefined {
|
|
218
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
|
219
|
+
const raw = (schema as Record<string, unknown>)["x-mcp-header"];
|
|
220
|
+
if (raw === undefined || raw === false) return undefined;
|
|
221
|
+
const name =
|
|
222
|
+
raw === true
|
|
223
|
+
? `Mcp-Param-${key
|
|
224
|
+
.split(/[-_]/)
|
|
225
|
+
.map((part) => (part[0] ? part[0].toUpperCase() + part.slice(1) : ""))
|
|
226
|
+
.join("")}`
|
|
227
|
+
: typeof raw === "string"
|
|
228
|
+
? raw
|
|
229
|
+
: null;
|
|
230
|
+
if (name === null || !isHttpHeaderToken(name) || !name.toLowerCase().startsWith("mcp-param-")) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return name;
|
|
234
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio MCP transport — cancel writes notifications/cancelled then kills.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { createMcpStdioTransport, type StdioChild } from "./mcp-stdio.ts";
|
|
7
|
+
|
|
8
|
+
describe("mcp stdio cancel", () => {
|
|
9
|
+
test("abort sends notifications/cancelled then kills the process", async () => {
|
|
10
|
+
const writes: string[] = [];
|
|
11
|
+
let killed = false;
|
|
12
|
+
const stdin = new TransformStream<Uint8Array, Uint8Array>();
|
|
13
|
+
const stdout = new TransformStream<Uint8Array, Uint8Array>();
|
|
14
|
+
const reader = stdin.readable.getReader();
|
|
15
|
+
const decoder = new TextDecoder();
|
|
16
|
+
void (async () => {
|
|
17
|
+
while (true) {
|
|
18
|
+
const { done, value } = await reader.read();
|
|
19
|
+
if (done) break;
|
|
20
|
+
writes.push(decoder.decode(value));
|
|
21
|
+
}
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
const child: StdioChild = {
|
|
25
|
+
stdin: stdin.writable,
|
|
26
|
+
stdout: stdout.readable,
|
|
27
|
+
kill() {
|
|
28
|
+
killed = true;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const transport = createMcpStdioTransport({
|
|
32
|
+
command: "mcp-server",
|
|
33
|
+
spawn: () => child,
|
|
34
|
+
});
|
|
35
|
+
const ctrl = new AbortController();
|
|
36
|
+
const pending = transport.request({
|
|
37
|
+
id: 1,
|
|
38
|
+
method: "tools/call",
|
|
39
|
+
params: { name: "create_issue" },
|
|
40
|
+
headers: {},
|
|
41
|
+
signal: ctrl.signal,
|
|
42
|
+
cancelMode: "stdio",
|
|
43
|
+
});
|
|
44
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
45
|
+
ctrl.abort();
|
|
46
|
+
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
|
47
|
+
expect(killed).toBe(true);
|
|
48
|
+
expect(writes.some((w) => w.includes("notifications/cancelled"))).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio MCP transport — newline-delimited JSON-RPC over a child process.
|
|
3
|
+
*
|
|
4
|
+
* Cancel = `notifications/cancelled` then kill the process.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { abortError } from "../../kernel/abort-scope.ts";
|
|
8
|
+
import { parseJsonRpcResponse } from "./mcp-protocol.ts";
|
|
9
|
+
import {
|
|
10
|
+
McpTransportError,
|
|
11
|
+
type McpTransport,
|
|
12
|
+
type McpTransportRequest,
|
|
13
|
+
type McpWireResult,
|
|
14
|
+
} from "./mcp-transport.ts";
|
|
15
|
+
|
|
16
|
+
/** Byte writer used by {@link StdioChild.stdin}. */
|
|
17
|
+
export interface StdioWriter {
|
|
18
|
+
write(data: Uint8Array): Promise<void> | void;
|
|
19
|
+
close(): Promise<void> | void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Options for {@link createMcpStdioTransport}. */
|
|
23
|
+
export interface CreateMcpStdioTransportOptions {
|
|
24
|
+
readonly command: string;
|
|
25
|
+
readonly args?: readonly string[];
|
|
26
|
+
readonly spawn?: (cmd: string, args: readonly string[]) => StdioChild;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Minimal child-process handle (Bun.spawn-compatible). */
|
|
30
|
+
export interface StdioChild {
|
|
31
|
+
readonly stdin: StdioWriter | WritableStream<Uint8Array> | null;
|
|
32
|
+
readonly stdout: ReadableStream<Uint8Array> | null;
|
|
33
|
+
readonly killed?: boolean;
|
|
34
|
+
kill(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Pending {
|
|
38
|
+
readonly resolve: (value: McpWireResult) => void;
|
|
39
|
+
readonly reject: (reason: unknown) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a stdio transport. The process is spawned on first request.
|
|
44
|
+
*
|
|
45
|
+
* @param options - Command + args (never a shell string)
|
|
46
|
+
*/
|
|
47
|
+
export function createMcpStdioTransport(options: CreateMcpStdioTransportOptions): McpTransport {
|
|
48
|
+
let child: StdioChild | undefined;
|
|
49
|
+
let writer: StdioWriter | undefined;
|
|
50
|
+
const pending = new Map<string, Pending>();
|
|
51
|
+
let readLoop: Promise<void> | undefined;
|
|
52
|
+
const encoder = new TextEncoder();
|
|
53
|
+
|
|
54
|
+
async function ensureChild(): Promise<void> {
|
|
55
|
+
if (child) return;
|
|
56
|
+
const spawn = options.spawn ?? defaultSpawn;
|
|
57
|
+
child = spawn(options.command, options.args ?? []);
|
|
58
|
+
if (!child.stdin || !child.stdout) {
|
|
59
|
+
throw new Error("ai.mcp: stdio process is missing stdin/stdout");
|
|
60
|
+
}
|
|
61
|
+
writer = asWriter(child.stdin);
|
|
62
|
+
readLoop = consumeStdout(child.stdout, pending);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
kind: "stdio",
|
|
67
|
+
async request(request: McpTransportRequest): Promise<McpWireResult> {
|
|
68
|
+
if (request.signal?.aborted) throw abortError(request.signal.reason);
|
|
69
|
+
await ensureChild();
|
|
70
|
+
if (!writer) throw new Error("ai.mcp: stdio stdin is not writable");
|
|
71
|
+
const key = String(request.id);
|
|
72
|
+
const envelope = JSON.stringify({
|
|
73
|
+
jsonrpc: "2.0",
|
|
74
|
+
id: request.id,
|
|
75
|
+
method: request.method,
|
|
76
|
+
...(request.params !== undefined ? { params: request.params } : {}),
|
|
77
|
+
});
|
|
78
|
+
const result = new Promise<McpWireResult>((resolve, reject) => {
|
|
79
|
+
pending.set(key, { resolve, reject });
|
|
80
|
+
});
|
|
81
|
+
const onAbort = (): void => {
|
|
82
|
+
void writeLine(
|
|
83
|
+
writer!,
|
|
84
|
+
encoder,
|
|
85
|
+
JSON.stringify({
|
|
86
|
+
jsonrpc: "2.0",
|
|
87
|
+
method: "notifications/cancelled",
|
|
88
|
+
params: { requestId: request.id },
|
|
89
|
+
}),
|
|
90
|
+
).finally(() => {
|
|
91
|
+
child?.kill();
|
|
92
|
+
child = undefined;
|
|
93
|
+
writer = undefined;
|
|
94
|
+
rejectAll(pending, abortError(request.signal?.reason));
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
98
|
+
try {
|
|
99
|
+
await writeLine(writer, encoder, envelope);
|
|
100
|
+
return await result;
|
|
101
|
+
} finally {
|
|
102
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
103
|
+
pending.delete(key);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
async close() {
|
|
107
|
+
child?.kill();
|
|
108
|
+
child = undefined;
|
|
109
|
+
try {
|
|
110
|
+
await writer?.close();
|
|
111
|
+
} catch {
|
|
112
|
+
/* already closed */
|
|
113
|
+
}
|
|
114
|
+
writer = undefined;
|
|
115
|
+
rejectAll(pending, new Error("ai.mcp: stdio transport closed"));
|
|
116
|
+
await readLoop?.catch(() => undefined);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function defaultSpawn(command: string, args: readonly string[]): StdioChild {
|
|
122
|
+
const proc = Bun.spawn([command, ...args], {
|
|
123
|
+
stdin: "pipe",
|
|
124
|
+
stdout: "pipe",
|
|
125
|
+
stderr: "pipe",
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
stdin: {
|
|
129
|
+
write(data) {
|
|
130
|
+
proc.stdin.write(data);
|
|
131
|
+
},
|
|
132
|
+
close() {
|
|
133
|
+
proc.stdin.end();
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
stdout: proc.stdout,
|
|
137
|
+
kill() {
|
|
138
|
+
proc.kill();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function asWriter(stdin: StdioWriter | WritableStream<Uint8Array>): StdioWriter {
|
|
144
|
+
if (isWritableStream(stdin)) {
|
|
145
|
+
const streamWriter = stdin.getWriter();
|
|
146
|
+
return {
|
|
147
|
+
write: (data) => streamWriter.write(data),
|
|
148
|
+
close: () => streamWriter.close(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return stdin;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isWritableStream(
|
|
155
|
+
value: StdioWriter | WritableStream<Uint8Array>,
|
|
156
|
+
): value is WritableStream<Uint8Array> {
|
|
157
|
+
return "getWriter" in value && typeof value.getWriter === "function";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function writeLine(writer: StdioWriter, encoder: TextEncoder, line: string): Promise<void> {
|
|
161
|
+
await writer.write(encoder.encode(`${line}\n`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function consumeStdout(
|
|
165
|
+
stdout: ReadableStream<Uint8Array>,
|
|
166
|
+
pending: Map<string, Pending>,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const decoder = new TextDecoder();
|
|
169
|
+
const reader = stdout.getReader();
|
|
170
|
+
let buf = "";
|
|
171
|
+
try {
|
|
172
|
+
while (true) {
|
|
173
|
+
const { done, value } = await reader.read();
|
|
174
|
+
if (done) break;
|
|
175
|
+
buf += decoder.decode(value, { stream: true });
|
|
176
|
+
const lines = buf.split("\n");
|
|
177
|
+
buf = lines.pop() ?? "";
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
const trimmed = line.trim();
|
|
180
|
+
if (!trimmed) continue;
|
|
181
|
+
let json: unknown;
|
|
182
|
+
try {
|
|
183
|
+
json = JSON.parse(trimmed) as unknown;
|
|
184
|
+
} catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const parsed = parseJsonRpcResponse(json);
|
|
188
|
+
if (!parsed) continue;
|
|
189
|
+
const key = parsed.id === null ? undefined : String(parsed.id);
|
|
190
|
+
if (key === undefined) continue;
|
|
191
|
+
const waiter = pending.get(key);
|
|
192
|
+
if (!waiter) continue;
|
|
193
|
+
pending.delete(key);
|
|
194
|
+
if (parsed.ok) {
|
|
195
|
+
waiter.resolve({ ok: true, result: parsed.result, rawBody: json });
|
|
196
|
+
} else {
|
|
197
|
+
waiter.resolve({ ok: false, error: parsed.error, rawBody: json });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
rejectAll(pending, err);
|
|
203
|
+
} finally {
|
|
204
|
+
reader.releaseLock();
|
|
205
|
+
rejectAll(pending, new McpTransportError("ai.mcp: stdio process ended", { network: true }));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function rejectAll(pending: Map<string, Pending>, reason: unknown): void {
|
|
210
|
+
for (const waiter of pending.values()) waiter.reject(reason);
|
|
211
|
+
pending.clear();
|
|
212
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP client transport contract — HTTP, stdio, and test mock.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { McpRpcError } from "./mcp-protocol.ts";
|
|
6
|
+
|
|
7
|
+
/** Outbound JSON-RPC request. */
|
|
8
|
+
export interface McpTransportRequest {
|
|
9
|
+
readonly id: string | number;
|
|
10
|
+
readonly method: string;
|
|
11
|
+
readonly params?: unknown;
|
|
12
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
13
|
+
readonly signal?: AbortSignal;
|
|
14
|
+
/** HTTP clients MUST abort fetch / SSE. stdio writes `notifications/cancelled` then kills. */
|
|
15
|
+
readonly cancelMode: "http" | "stdio";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Wire result (HTTP status present only for Streamable HTTP). */
|
|
19
|
+
export interface McpWireResult {
|
|
20
|
+
readonly ok: boolean;
|
|
21
|
+
readonly result?: unknown;
|
|
22
|
+
readonly error?: McpRpcError;
|
|
23
|
+
readonly httpStatus?: number;
|
|
24
|
+
readonly sessionId?: string;
|
|
25
|
+
readonly rawBody?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Bidirectional MCP transport. */
|
|
29
|
+
export interface McpTransport {
|
|
30
|
+
readonly kind: "http" | "stdio" | "mock";
|
|
31
|
+
/**
|
|
32
|
+
* Send one JSON-RPC request and wait for the matching response.
|
|
33
|
+
*
|
|
34
|
+
* @param request - Envelope + headers + abort
|
|
35
|
+
*/
|
|
36
|
+
request(request: McpTransportRequest): Promise<McpWireResult>;
|
|
37
|
+
/** Release the process / connection. */
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Transport / protocol failure with enough shape for era fallback.
|
|
43
|
+
*/
|
|
44
|
+
export class McpTransportError extends Error {
|
|
45
|
+
readonly httpStatus?: number;
|
|
46
|
+
readonly rpc?: McpRpcError;
|
|
47
|
+
readonly network?: boolean;
|
|
48
|
+
readonly timeout?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param message - Diagnostic
|
|
52
|
+
* @param extras - HTTP / RPC / network flags
|
|
53
|
+
*/
|
|
54
|
+
constructor(
|
|
55
|
+
message: string,
|
|
56
|
+
extras: {
|
|
57
|
+
readonly httpStatus?: number;
|
|
58
|
+
readonly rpc?: McpRpcError;
|
|
59
|
+
readonly network?: boolean;
|
|
60
|
+
readonly timeout?: boolean;
|
|
61
|
+
} = {},
|
|
62
|
+
) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "McpTransportError";
|
|
65
|
+
if (extras.httpStatus !== undefined) this.httpStatus = extras.httpStatus;
|
|
66
|
+
if (extras.rpc !== undefined) this.rpc = extras.rpc;
|
|
67
|
+
if (extras.network !== undefined) this.network = extras.network;
|
|
68
|
+
if (extras.timeout !== undefined) this.timeout = extras.timeout;
|
|
69
|
+
}
|
|
70
|
+
}
|