@ossy/platform 1.39.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +21 -17
  2. package/package.json +20 -11
  3. package/src/Definition.js +2 -1
  4. package/src/PlatformShell.jsx +10 -10
  5. package/src/actions/action.service.js +85 -7
  6. package/src/audit/action-invocation-service.js +143 -0
  7. package/src/audit/action-invocation.aggregate.js +57 -0
  8. package/src/audit/audit-helpers.js +61 -0
  9. package/src/audit/detect-channel.js +19 -0
  10. package/src/audit/list-task-runs.action.js +5 -0
  11. package/src/audit/list-task-runs.task.js +17 -0
  12. package/src/audit/task-run-list.aggregate.js +76 -0
  13. package/src/audit/task-run-service.js +208 -0
  14. package/src/audit/task-run.aggregate.js +58 -0
  15. package/src/auth/action-scopes.js +3 -0
  16. package/src/capability-schemas/action-capability.schema.js +1 -0
  17. package/src/capability-schemas/action-meta.schema.js +1 -0
  18. package/src/capability-schemas/component-capability.schema.js +1 -0
  19. package/src/capability-schemas/page-capability.schema.js +1 -0
  20. package/src/capability-schemas/page-meta.schema.js +1 -0
  21. package/src/capability-schemas/task-capability.schema.js +1 -0
  22. package/src/capability-schemas/task-meta.schema.js +1 -0
  23. package/src/capability-schemas/task-output.schema.js +1 -0
  24. package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
  25. package/src/capability-schemas/task-trigger.schema.js +1 -0
  26. package/src/directory.schema.js +7 -0
  27. package/src/entitlements/action-entitlement.js +69 -0
  28. package/src/file.schema.js +11 -0
  29. package/src/index.js +12 -3
  30. package/src/mcp/create-ossy-mcp-server.js +97 -0
  31. package/src/mcp/json-schema-to-zod.js +64 -0
  32. package/src/mcp/mount-ossy-mcp.js +72 -0
  33. package/src/mcp/mount-platform-mcp.js +101 -0
  34. package/src/mcp/upload-file-tool.js +62 -0
  35. package/src/metering/metering-service.js +91 -0
  36. package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
  37. package/src/proxy-internal.js +13 -16
  38. package/src/push/mount-push-sse.js +91 -0
  39. package/src/request-diagnostics.js +144 -0
  40. package/src/request-diagnostics.spec.js +40 -0
  41. package/src/resources/index.js +3 -2
  42. package/src/resources/schema.registry.js +26 -0
  43. package/src/resources/schema.service.js +54 -0
  44. package/src/resources/schema.validation.js +90 -0
  45. package/src/runtime.js +20 -6
  46. package/src/server.js +281 -62
  47. package/src/storage/filesystem-storage.client.js +109 -0
  48. package/src/storage/local-storage.client.js +2 -65
  49. package/src/storage/resource-read-url.js +36 -0
  50. package/src/storage/resource-read-url.spec.js +22 -0
  51. package/src/storage/s3-storage.client.js +102 -0
  52. package/src/storage/s3.client.js +27 -23
  53. package/src/storage/storage-keys.js +37 -0
  54. package/src/storage/storage-keys.spec.js +16 -0
  55. package/src/storage/storage.client.js +52 -8
  56. package/src/storage/storage.integration.js +40 -0
  57. package/src/tasks/change-stream.js +78 -11
  58. package/src/tasks/task-service.js +211 -34
  59. package/src/tasks/task-service.spec.js +187 -0
  60. package/src/test/e2e.util.js +18 -39
  61. package/src/test/flow-runner.js +476 -0
  62. package/src/test/test.util.js +30 -29
  63. package/src/user-app-settings.js +44 -0
  64. package/src/users.middleware.js +61 -17
  65. package/src/resources/resource-template.registry.js +0 -29
  66. package/src/resources/resource-template.validation.js +0 -232
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ADR 0008 projection — workspace-scoped task run list for UI and billing queries.
3
+ */
4
+ export class TaskRunListProjection {
5
+
6
+ static kind = 'projection'
7
+ static ProjectionId = '@ossy/platform/data/task-run-list'
8
+
9
+ static sources = [
10
+ { type: '@ossy/platform/schema/task-run', event: 'Started' },
11
+ { type: '@ossy/platform/schema/task-run', event: 'Completed' },
12
+ { type: '@ossy/platform/schema/task-run', event: 'Failed' },
13
+ ]
14
+
15
+ static scopeFromEvent (event) {
16
+ return event.payload?.belongsTo ?? null
17
+ }
18
+
19
+ static cacheKeys (scopeId) {
20
+ return [`projection:${TaskRunListProjection.ProjectionId}:${scopeId}`]
21
+ }
22
+
23
+ static initialState (scopeId) {
24
+ return { workspaceId: scopeId, runs: [] }
25
+ }
26
+
27
+ static Apply (event, state) {
28
+ const workspaceId = event.payload?.belongsTo ?? state.workspaceId
29
+ const entry = {
30
+ id: event.resourceId,
31
+ taskId: event.payload.taskId,
32
+ moduleId: event.payload.moduleId,
33
+ trigger: event.payload.trigger,
34
+ actionInvocationId: event.payload.actionInvocationId ?? null,
35
+ actorId: event.payload.actorId ?? event.createdBy ?? null,
36
+ executionEnv: event.payload.executionEnv ?? 'ossy_server',
37
+ updatedAt: event.created,
38
+ }
39
+
40
+ const runs = [...(state.runs ?? [])]
41
+
42
+ if (event.event === 'Started') {
43
+ entry.status = 'in progress'
44
+ entry.startedAt = event.created
45
+ runs.unshift(entry)
46
+ return { workspaceId, runs: runs.slice(0, 200) }
47
+ }
48
+
49
+ const idx = runs.findIndex(r => r.id === event.resourceId)
50
+ if (idx < 0) {
51
+ runs.unshift({
52
+ ...entry,
53
+ status: event.event === 'Completed' ? 'success' : 'failed',
54
+ startedAt: event.created,
55
+ completedAt: event.created,
56
+ durationMs: event.payload.durationMs,
57
+ })
58
+ return { workspaceId, runs: runs.slice(0, 200) }
59
+ }
60
+
61
+ runs[idx] = {
62
+ ...runs[idx],
63
+ ...entry,
64
+ status: event.event === 'Completed' ? 'success' : 'failed',
65
+ completedAt: event.created,
66
+ durationMs: event.payload.durationMs,
67
+ error: event.event === 'Failed' ? event.payload.error : undefined,
68
+ resultSummary: event.event === 'Completed' ? event.payload.resultSummary : undefined,
69
+ }
70
+
71
+ return { workspaceId, runs }
72
+ }
73
+ }
74
+
75
+ export { TaskRunListProjection as Aggregate }
76
+ export const id = 'platform/data/task-run-list'
@@ -0,0 +1,208 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { Aggregate } from '@ossy/event-store'
3
+ import { createLogger, metrics } from '@ossy/observability'
4
+ import { TaskRun } from './task-run.aggregate.js'
5
+ import { MeteringService } from '../metering/metering-service.js'
6
+ import { detectChannel } from './detect-channel.js'
7
+ import {
8
+ moduleIdFromTaskId,
9
+ resolveActorId,
10
+ resolveWorkspaceId,
11
+ summarizeError,
12
+ summarizeResult,
13
+ } from './audit-helpers.js'
14
+ import { rejectOnAbort } from '../request-diagnostics.js'
15
+
16
+ const log = createLogger('platform/task-run')
17
+
18
+ function assertNotAborted (context) {
19
+ if (context.signal?.aborted) {
20
+ throw context.signal.reason instanceof Error
21
+ ? context.signal.reason
22
+ : new Error('Operation aborted')
23
+ }
24
+ }
25
+
26
+ function recordTaskMetrics (taskId, durationMs) {
27
+ try { metrics.timing('task.duration', durationMs, { task: taskId }) } catch {}
28
+ try { metrics.increment('task.run', { task: taskId }) } catch {}
29
+ }
30
+
31
+ /**
32
+ * Records task execution lifecycle as ADR 0008 entity events.
33
+ */
34
+ export const TaskRunService = {
35
+
36
+ /**
37
+ * Run a task handler with audit lifecycle (sync path).
38
+ *
39
+ * @param {{
40
+ * taskId: string,
41
+ * handler: (context: object) => Promise<unknown>,
42
+ * context?: object,
43
+ * trigger: 'on_action' | 'on_event' | 'on_schedule' | 'invoke',
44
+ * triggeredBy?: object,
45
+ * executionEnv?: string,
46
+ * }} opts
47
+ */
48
+ async execute (opts) {
49
+ const {
50
+ taskId,
51
+ handler,
52
+ context = {},
53
+ trigger,
54
+ triggeredBy = {},
55
+ executionEnv = 'ossy_server',
56
+ audit = true,
57
+ } = opts
58
+
59
+ if (audit === false) {
60
+ assertNotAborted(context)
61
+ if (context.signal) {
62
+ return Promise.race([handler(context), rejectOnAbort(context.signal)])
63
+ }
64
+ return handler(context)
65
+ }
66
+
67
+ const runId = nanoid()
68
+ const workspaceId = resolveWorkspaceId(context)
69
+ const actorId = resolveActorId(context)
70
+ const startMs = Date.now()
71
+
72
+ context.audit = context.audit ?? {}
73
+ context.audit.taskRunId = runId
74
+
75
+ await TaskRunService._appendStarted({
76
+ runId,
77
+ taskId,
78
+ trigger,
79
+ triggeredBy,
80
+ workspaceId,
81
+ actorId,
82
+ executionEnv,
83
+ actionInvocationId: context.actionInvocationId ?? null,
84
+ })
85
+
86
+ try {
87
+ assertNotAborted(context)
88
+ const result = context.signal
89
+ ? await Promise.race([handler(context), rejectOnAbort(context.signal)])
90
+ : await handler(context)
91
+ const durationMs = Date.now() - startMs
92
+ await TaskRunService._appendCompleted({
93
+ runId,
94
+ actorId,
95
+ durationMs,
96
+ resultSummary: summarizeResult(result),
97
+ })
98
+ recordTaskMetrics(taskId, durationMs)
99
+ await MeteringService.record({
100
+ kind: 'task',
101
+ actionId: context.actionId ?? null,
102
+ taskId,
103
+ channel: detectChannel(context.req),
104
+ workspaceId,
105
+ actorId,
106
+ durationMs,
107
+ success: true,
108
+ actionInvocationId: context.actionInvocationId ?? null,
109
+ taskRunId: runId,
110
+ trigger,
111
+ })
112
+ return result
113
+ } catch (error) {
114
+ const durationMs = Date.now() - startMs
115
+ await TaskRunService._appendFailed({
116
+ runId,
117
+ actorId,
118
+ durationMs,
119
+ error: summarizeError(error),
120
+ })
121
+ recordTaskMetrics(taskId, durationMs)
122
+ await MeteringService.record({
123
+ kind: 'task',
124
+ actionId: context.actionId ?? null,
125
+ taskId,
126
+ channel: detectChannel(context.req),
127
+ workspaceId,
128
+ actorId,
129
+ durationMs,
130
+ success: false,
131
+ actionInvocationId: context.actionInvocationId ?? null,
132
+ taskRunId: runId,
133
+ trigger,
134
+ })
135
+ throw error
136
+ }
137
+ },
138
+
139
+ /**
140
+ * Fire-and-forget task execution with audit lifecycle (async path).
141
+ *
142
+ * @param {Parameters<typeof TaskRunService.execute>[0]} opts
143
+ */
144
+ executeAsync (opts) {
145
+ TaskRunService.execute(opts).catch(error => {
146
+ log.error(`Async task run failed: "${opts.taskId}"`, { taskId: opts.taskId }, error)
147
+ })
148
+ },
149
+
150
+ async _appendStarted ({
151
+ runId,
152
+ taskId,
153
+ trigger,
154
+ triggeredBy,
155
+ workspaceId,
156
+ actorId,
157
+ executionEnv,
158
+ actionInvocationId,
159
+ }) {
160
+ try {
161
+ await Aggregate.Of(TaskRun, {
162
+ resourceId: runId,
163
+ type: '@ossy/platform/schema/task-run',
164
+ event: 'Started',
165
+ createdBy: actorId ?? undefined,
166
+ payload: {
167
+ taskId,
168
+ moduleId: moduleIdFromTaskId(taskId),
169
+ trigger,
170
+ triggeredBy,
171
+ executionEnv,
172
+ actionInvocationId,
173
+ actorId,
174
+ belongsTo: workspaceId,
175
+ location: '/@ossy/tasks/runs/',
176
+ },
177
+ })
178
+ } catch (err) {
179
+ log.warn('[TaskRunService] Failed to record Started', { taskId, runId }, err)
180
+ }
181
+ },
182
+
183
+ async _appendCompleted ({ runId, actorId, durationMs, resultSummary }) {
184
+ try {
185
+ await Aggregate.Of(TaskRun, runId)
186
+ .then(Aggregate.Add({
187
+ event: 'Completed',
188
+ createdBy: actorId ?? undefined,
189
+ payload: { durationMs, resultSummary },
190
+ }))
191
+ } catch (err) {
192
+ log.warn('[TaskRunService] Failed to record Completed', { runId }, err)
193
+ }
194
+ },
195
+
196
+ async _appendFailed ({ runId, actorId, durationMs, error }) {
197
+ try {
198
+ await Aggregate.Of(TaskRun, runId)
199
+ .then(Aggregate.Add({
200
+ event: 'Failed',
201
+ createdBy: actorId ?? undefined,
202
+ payload: { durationMs, error },
203
+ }))
204
+ } catch (err) {
205
+ log.warn('[TaskRunService] Failed to record Failed', { runId }, err)
206
+ }
207
+ },
208
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ADR 0008 entity — one stream per task execution (sync invoke, changestream, cron, on_action).
3
+ */
4
+ export class TaskRun {
5
+
6
+ static kind = 'entity'
7
+ static SchemaId = '@ossy/platform/schema/task-run'
8
+ static AggregateType = 'TaskRun'
9
+
10
+ static View (events, state = {}) {
11
+ return events.reduce((run, event) => {
12
+ switch (event.event) {
13
+
14
+ case 'Started':
15
+ return {
16
+ ...run,
17
+ id: event.resourceId,
18
+ type: event.type,
19
+ taskId: event.payload.taskId,
20
+ moduleId: event.payload.moduleId,
21
+ status: 'in progress',
22
+ trigger: event.payload.trigger,
23
+ triggeredBy: event.payload.triggeredBy ?? {},
24
+ actionInvocationId: event.payload.actionInvocationId ?? null,
25
+ actorId: event.payload.actorId ?? event.createdBy ?? null,
26
+ workspaceId: event.payload.belongsTo ?? null,
27
+ executionEnv: event.payload.executionEnv ?? 'ossy_server',
28
+ startedAt: event.created,
29
+ created: event.created,
30
+ }
31
+
32
+ case 'Completed':
33
+ return {
34
+ ...run,
35
+ status: 'success',
36
+ completedAt: event.created,
37
+ durationMs: event.payload.durationMs,
38
+ resultSummary: event.payload.resultSummary ?? null,
39
+ }
40
+
41
+ case 'Failed':
42
+ return {
43
+ ...run,
44
+ status: 'failed',
45
+ completedAt: event.created,
46
+ durationMs: event.payload.durationMs,
47
+ error: event.payload.error ?? null,
48
+ }
49
+
50
+ default:
51
+ return run
52
+ }
53
+ }, state)
54
+ }
55
+ }
56
+
57
+ export { TaskRun as Aggregate }
58
+ export const id = 'platform/schema/task-run'
@@ -0,0 +1,3 @@
1
+ import { normalizeScopes, assertActionScoped, isActionAllowedByScopes } from '@ossy/schema'
2
+
3
+ export { normalizeScopes, assertActionScoped, isActionAllowedByScopes }
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/action-capability.schema.js'
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/action-meta.schema.js'
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/component-capability.schema.js'
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/page-capability.schema.js'
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/page-meta.schema.js'
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/task-capability.schema.js'
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/task-meta.schema.js'
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/task-output.schema.js'
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/task-trigger-authoring.schema.js'
@@ -0,0 +1 @@
1
+ export { default } from '@ossy/schema/capability-schemas/task-trigger.schema.js'
@@ -0,0 +1,7 @@
1
+ export default {
2
+ id: '@ossy/platform/schema/directory',
3
+ name: 'Directory',
4
+ categoryName: 'Platform',
5
+ icon: 'folder',
6
+ fields: [],
7
+ }
@@ -0,0 +1,69 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { isServiceEntitled, packageNameToSlug } from '@ossy/workspaces/entitlements'
3
+
4
+ /** @typedef {{ packageName: string, entitlementRequired: boolean }} ActionEntitlementMeta */
5
+
6
+ /**
7
+ * @param {{ actions?: Array<{ id: string, package?: string }>, definitions?: Record<string, { entitlementRequired?: boolean }> }} manifest
8
+ * @returns {Map<string, ActionEntitlementMeta>}
9
+ */
10
+ export function buildActionEntitlementIndex (manifest) {
11
+ /** @type {Map<string, ActionEntitlementMeta>} */
12
+ const index = new Map()
13
+ for (const action of manifest.actions || []) {
14
+ if (!action.package) continue
15
+ const slug = packageNameToSlug(action.package)
16
+ const def = manifest.definitions?.[slug]
17
+ const entitlementRequired = def?.entitlementRequired !== false
18
+ index.set(action.id, { packageName: action.package, entitlementRequired })
19
+ }
20
+ return index
21
+ }
22
+
23
+ /**
24
+ * Enforce workspace package entitlements for workspace-scoped actions (ADR 0010 Wave 1).
25
+ *
26
+ * @param {{
27
+ * actionId: string,
28
+ * access: string,
29
+ * workspaceId?: string,
30
+ * entitlementIndex: Map<string, ActionEntitlementMeta>,
31
+ * loadWorkspace: (workspaceId: string) => Promise<{ services?: Record<string, unknown> } | null>,
32
+ * }} opts
33
+ */
34
+ export async function assertActionEntitled ({
35
+ actionId,
36
+ access,
37
+ workspaceId,
38
+ entitlementIndex,
39
+ loadWorkspace,
40
+ }) {
41
+ if (access !== 'workspace' || !workspaceId) return
42
+
43
+ const meta = entitlementIndex.get(actionId)
44
+ if (!meta?.entitlementRequired) return
45
+
46
+ const workspace = await loadWorkspace(workspaceId)
47
+ if (!workspace) {
48
+ throw Object.assign(new Error('Workspace not found'), { status: 404 })
49
+ }
50
+
51
+ if (!isServiceEntitled(workspace.services, meta.packageName)) {
52
+ throw Object.assign(new Error(`Service ${meta.packageName} is not entitled for this workspace`), {
53
+ status: 403,
54
+ code: 'SERVICE_NOT_ENTITLED',
55
+ package: meta.packageName,
56
+ })
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {import('@ossy/workspaces/server').Workspace} WorkspaceAggregate
62
+ * @returns {(workspaceId: string) => Promise<{ services?: Record<string, unknown> } | null>}
63
+ */
64
+ export function createWorkspaceLoader (WorkspaceAggregate) {
65
+ return async (workspaceId) => {
66
+ const workspace = await Aggregate.Of(WorkspaceAggregate, workspaceId).then(Aggregate.View())
67
+ return workspace?.id ? workspace : null
68
+ }
69
+ }
@@ -0,0 +1,11 @@
1
+ export default {
2
+ id: '@ossy/platform/schema/file',
3
+ name: 'File',
4
+ categoryName: 'Platform',
5
+ icon: 'file',
6
+ fields: [
7
+ { name: 'Key', type: 'text' },
8
+ { name: 'ContentLength', type: 'number' },
9
+ { name: 'ContentType', type: 'text' },
10
+ ],
11
+ }
package/src/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  export { TaskService } from './tasks/task-service.js'
2
2
  export { loadAndRegisterTasks } from './tasks/task-registry.js'
3
3
  export { ChangeStream } from './tasks/change-stream.js'
4
- export { registerResourceTemplate, getSystemResourceTemplates } from './resources/resource-template.registry.js'
5
- export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport, ALLOWED_FIELD_TYPES } from './resources/resource-template.validation.js'
6
- export { Definition } from './Definition.js'
4
+ export { registerSchema, getSystemSchemas } from './resources/schema.registry.js'
5
+ export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWorkspace } from './resources/schema.service.js'
6
+ export { validateSchemasForImport, ALLOWED_FIELD_TYPES, normalizeFieldType, resolveFieldDef } from './resources/schema.validation.js'
7
7
  export { IntegrationService } from './integration.service.js'
8
8
  export { ConfigService } from './config.service.js'
9
9
  export { ActionService } from './actions/action.service.js'
@@ -12,3 +12,12 @@ export { UsersMiddleware } from './users.middleware.js'
12
12
  export { WorkspacesMiddleware } from './workspaces.middleware.js'
13
13
  export { matchesCron } from './tasks/cron.js'
14
14
  export { matchesGlob, globToRegex, policyToQueryClause } from './tasks/glob.js'
15
+ export {
16
+ USER_SETTINGS_COOKIE,
17
+ AUTH_COOKIE,
18
+ readUserAppSettings,
19
+ mergeUserAppSettingsCookie,
20
+ clearWorkspaceFromUserAppSettings,
21
+ setAuthCookie,
22
+ clearAuthCookie,
23
+ } from './user-app-settings.js'
@@ -0,0 +1,97 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { jsonSchemaToZodShape } from './json-schema-to-zod.js'
3
+ import {
4
+ TASK_TOPOLOGY_RESOURCE_URI,
5
+ capabilitiesTaskTopologyResource,
6
+ } from '@ossy/app/manifest/build-capabilities'
7
+
8
+ /**
9
+ * @param {{
10
+ * capabilities: { tools: Array<object>, tasks?: object[], graph?: object },
11
+ * invokeAction: (actionId: string, payload: object, req?: object) => Promise<unknown>,
12
+ * customTools?: Array<{
13
+ * name: string,
14
+ * description: string,
15
+ * inputSchema?: object,
16
+ * handler: (args: object, req?: object) => Promise<unknown>,
17
+ * }>,
18
+ * }} options
19
+ */
20
+ export function createOssyMcpServer ({ capabilities, invokeAction, customTools = [] }) {
21
+ const server = new McpServer({
22
+ name: 'ossy',
23
+ version: '0.2.0',
24
+ })
25
+
26
+ const topology = capabilitiesTaskTopologyResource(capabilities)
27
+ if (topology.tasks.length || topology.graph.edges?.length) {
28
+ server.registerResource(
29
+ 'task-topology',
30
+ TASK_TOPOLOGY_RESOURCE_URI,
31
+ {
32
+ title: 'Task topology',
33
+ description: 'Read-only task catalog and trigger graph for multi-step agent planning (ADR 0011).',
34
+ mimeType: 'application/json',
35
+ },
36
+ async () => ({
37
+ contents: [{
38
+ uri: TASK_TOPOLOGY_RESOURCE_URI,
39
+ mimeType: 'application/json',
40
+ text: JSON.stringify(topology, null, 2),
41
+ }],
42
+ }),
43
+ )
44
+ }
45
+
46
+ for (const tool of capabilities.tools || []) {
47
+ const inputShape = jsonSchemaToZodShape(tool.inputSchema || { type: 'object' })
48
+ server.registerTool(
49
+ tool.name,
50
+ {
51
+ description: tool.description || tool.title || tool.actionId,
52
+ inputSchema: inputShape,
53
+ },
54
+ async (args, extra) => {
55
+ try {
56
+ const result = await invokeAction(tool.actionId, args, extra?.requestInfo)
57
+ return {
58
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
59
+ }
60
+ } catch (err) {
61
+ return {
62
+ isError: true,
63
+ content: [{ type: 'text', text: err?.message || String(err) }],
64
+ }
65
+ }
66
+ },
67
+ )
68
+ }
69
+
70
+ for (const tool of customTools) {
71
+ const inputShape = tool.inputSchema
72
+ ? jsonSchemaToZodShape(tool.inputSchema)
73
+ : undefined
74
+ server.registerTool(
75
+ tool.name,
76
+ {
77
+ description: tool.description,
78
+ ...(inputShape ? { inputSchema: inputShape } : {}),
79
+ },
80
+ async (args, extra) => {
81
+ try {
82
+ const result = await tool.handler(args, extra?.requestInfo)
83
+ return {
84
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
85
+ }
86
+ } catch (err) {
87
+ return {
88
+ isError: true,
89
+ content: [{ type: 'text', text: err?.message || String(err) }],
90
+ }
91
+ }
92
+ },
93
+ )
94
+ }
95
+
96
+ return server
97
+ }
@@ -0,0 +1,64 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Minimal JSON Schema → Zod conversion for MCP tool registration.
5
+ * Supports the subset produced by build-capabilities.
6
+ *
7
+ * @param {object} schema
8
+ * @returns {import('zod').ZodTypeAny}
9
+ */
10
+ export function jsonSchemaToZod (schema) {
11
+ if (!schema || typeof schema !== 'object') {
12
+ return z.record(z.unknown())
13
+ }
14
+
15
+ if (Array.isArray(schema.oneOf) && schema.oneOf.length) {
16
+ return z.union(schema.oneOf.map(jsonSchemaToZod))
17
+ }
18
+
19
+ if (schema.type === 'object' || schema.properties) {
20
+ const shape = {}
21
+ for (const [key, propSchema] of Object.entries(schema.properties || {})) {
22
+ let field = jsonSchemaToZod(propSchema)
23
+ if (!schema.required?.includes(key)) {
24
+ field = field.optional()
25
+ }
26
+ shape[key] = field
27
+ }
28
+ const objectSchema = z.object(shape)
29
+ return schema.additionalProperties ? objectSchema.passthrough() : objectSchema
30
+ }
31
+
32
+ if (schema.type === 'array') {
33
+ return z.array(jsonSchemaToZod(schema.items || {}))
34
+ }
35
+
36
+ if (schema.type === 'number' || schema.type === 'integer') {
37
+ let field = z.number()
38
+ if (schema.description) field = field.describe(schema.description)
39
+ return field
40
+ }
41
+
42
+ if (schema.type === 'boolean') {
43
+ return z.boolean()
44
+ }
45
+
46
+ let field = z.string()
47
+ if (schema.description) field = field.describe(schema.description)
48
+ if (Array.isArray(schema.enum) && schema.enum.length) {
49
+ field = z.enum(schema.enum)
50
+ }
51
+ return field
52
+ }
53
+
54
+ /**
55
+ * @param {object} inputSchema JSON Schema object
56
+ * @returns {import('zod').ZodRawShape}
57
+ */
58
+ export function jsonSchemaToZodShape (inputSchema) {
59
+ const zodSchema = jsonSchemaToZod(inputSchema)
60
+ if (zodSchema instanceof z.ZodObject) {
61
+ return zodSchema.shape
62
+ }
63
+ return { payload: zodSchema }
64
+ }