@lokascript/domain-flow 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ /**
2
+ * MCP Workflow Server — HATEOAS → MCP tool bridge
3
+ *
4
+ * An MCP server that wraps a Siren API, exposing current entity affordances
5
+ * (actions and links) as dynamic MCP tools. As the agent navigates through
6
+ * hypermedia states, tools appear and disappear — `tools/list_changed` fires
7
+ * on every state transition.
8
+ *
9
+ * This is the novel contribution: MCP's tool protocol becomes a first-class
10
+ * HATEOAS mechanism. External LLM clients (Claude Code, Cursor, etc.) see
11
+ * affordance-driven tool sets without needing to understand Siren.
12
+ *
13
+ * Tool naming convention:
14
+ * action__{name} — Execute a Siren action
15
+ * navigate__{rel} — Follow a Siren link
16
+ * resolve__{name} — Resolve a 409 prerequisite action
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * import { createMcpWorkflowServer } from '@lokascript/domain-flow/runtime';
21
+ *
22
+ * const server = createMcpWorkflowServer({
23
+ * entryPoint: 'http://api.example.com/',
24
+ * name: 'order-api',
25
+ * version: '1.0.0',
26
+ * });
27
+ *
28
+ * await server.start(); // Connects to API, builds initial tools, listens on stdio
29
+ * ```
30
+ */
31
+
32
+ import type { WorkflowSpec } from '../types.js';
33
+
34
+ // =============================================================================
35
+ // Types
36
+ // =============================================================================
37
+
38
+ /** Siren entity structure (subset for our needs) */
39
+ interface SirenEntity {
40
+ class?: string[];
41
+ properties?: Record<string, unknown>;
42
+ entities?: Array<Record<string, unknown>>;
43
+ actions?: SirenAction[];
44
+ links?: SirenLink[];
45
+ }
46
+
47
+ interface SirenAction {
48
+ name: string;
49
+ title?: string;
50
+ href: string;
51
+ method?: string;
52
+ type?: string;
53
+ fields?: SirenField[];
54
+ }
55
+
56
+ interface SirenField {
57
+ name: string;
58
+ type?: string;
59
+ value?: unknown;
60
+ title?: string;
61
+ }
62
+
63
+ interface SirenLink {
64
+ rel: string[];
65
+ href: string;
66
+ title?: string;
67
+ type?: string;
68
+ }
69
+
70
+ /** MCP tool definition */
71
+ interface McpTool {
72
+ name: string;
73
+ description: string;
74
+ inputSchema: {
75
+ type: 'object';
76
+ properties: Record<string, unknown>;
77
+ required?: string[];
78
+ };
79
+ }
80
+
81
+ /** Configuration for the MCP workflow server */
82
+ export interface McpWorkflowServerConfig {
83
+ /** API entry point URL */
84
+ entryPoint: string;
85
+ /** Server name for MCP identification */
86
+ name?: string;
87
+ /** Server version */
88
+ version?: string;
89
+ /** HTTP headers (e.g., auth) for all API requests */
90
+ headers?: Record<string, string>;
91
+ /** Enable verbose logging */
92
+ verbose?: boolean;
93
+ /** Optional pre-compiled WorkflowSpec to auto-execute */
94
+ workflow?: WorkflowSpec;
95
+ }
96
+
97
+ // =============================================================================
98
+ // Tool Generation from Siren Affordances
99
+ // =============================================================================
100
+
101
+ /**
102
+ * Convert Siren actions to MCP tool definitions.
103
+ *
104
+ * Each action becomes an `action__{name}` tool with input schema
105
+ * generated from the action's fields.
106
+ */
107
+ export function actionsToTools(actions: SirenAction[]): McpTool[] {
108
+ return actions.map(action => {
109
+ const properties: Record<string, unknown> = {};
110
+ const required: string[] = [];
111
+
112
+ for (const field of action.fields ?? []) {
113
+ properties[field.name] = {
114
+ type: fieldTypeToJsonSchema(field.type),
115
+ ...(field.title ? { description: field.title } : {}),
116
+ ...(field.value !== undefined ? { default: field.value } : {}),
117
+ };
118
+ // Siren fields are required by default unless they have a default value
119
+ if (field.value === undefined) {
120
+ required.push(field.name);
121
+ }
122
+ }
123
+
124
+ return {
125
+ name: `action__${action.name}`,
126
+ description:
127
+ action.title ??
128
+ `Execute the "${action.name}" action (${action.method ?? 'POST'} ${action.href})`,
129
+ inputSchema: {
130
+ type: 'object' as const,
131
+ properties,
132
+ ...(required.length > 0 ? { required } : {}),
133
+ },
134
+ };
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Convert Siren links to MCP tool definitions.
140
+ *
141
+ * Each link becomes a `navigate__{rel}` tool with no required inputs.
142
+ */
143
+ export function linksToTools(links: SirenLink[]): McpTool[] {
144
+ // Deduplicate by first rel
145
+ const seen = new Set<string>();
146
+ const tools: McpTool[] = [];
147
+
148
+ for (const link of links) {
149
+ const rel = link.rel[0];
150
+ if (!rel || rel === 'self' || seen.has(rel)) continue;
151
+ seen.add(rel);
152
+
153
+ tools.push({
154
+ name: `navigate__${rel}`,
155
+ description: link.title ?? `Navigate to "${rel}" (${link.href})`,
156
+ inputSchema: {
157
+ type: 'object' as const,
158
+ properties: {},
159
+ },
160
+ });
161
+ }
162
+
163
+ return tools;
164
+ }
165
+
166
+ /**
167
+ * Convert Siren field type to JSON Schema type.
168
+ */
169
+ function fieldTypeToJsonSchema(type?: string): string {
170
+ switch (type) {
171
+ case 'number':
172
+ case 'range':
173
+ return 'number';
174
+ case 'checkbox':
175
+ return 'boolean';
176
+ case 'hidden':
177
+ case 'text':
178
+ case 'email':
179
+ case 'url':
180
+ case 'password':
181
+ case 'search':
182
+ case 'tel':
183
+ case 'date':
184
+ case 'datetime-local':
185
+ case 'time':
186
+ case 'month':
187
+ case 'week':
188
+ default:
189
+ return 'string';
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Build the complete tool set from a Siren entity.
195
+ */
196
+ export function entityToTools(entity: SirenEntity): McpTool[] {
197
+ return [...actionsToTools(entity.actions ?? []), ...linksToTools(entity.links ?? [])];
198
+ }
199
+
200
+ // =============================================================================
201
+ // MCP Server (protocol-level, transport-agnostic)
202
+ // =============================================================================
203
+
204
+ /**
205
+ * MCP Workflow Server state machine.
206
+ *
207
+ * Manages the Siren agent state and translates between MCP tool
208
+ * calls and Siren API interactions. Transport (stdio, SSE, etc.)
209
+ * is handled externally.
210
+ *
211
+ * The server lifecycle:
212
+ * 1. initialize() — fetch entry point, build initial tool set
213
+ * 2. listTools() — return current affordance-derived tools
214
+ * 3. callTool(name, args) — execute action or navigate, rebuild tools
215
+ * 4. On each state change, emit tools/list_changed notification
216
+ */
217
+ export class McpWorkflowServer {
218
+ private config: Required<Pick<McpWorkflowServerConfig, 'entryPoint' | 'name' | 'version'>> &
219
+ McpWorkflowServerConfig;
220
+ private currentEntity: SirenEntity | null = null;
221
+ private currentUrl: string;
222
+ private currentTools: McpTool[] = [];
223
+ private toolChangeListeners: Array<() => void> = [];
224
+
225
+ constructor(config: McpWorkflowServerConfig) {
226
+ this.config = {
227
+ name: 'hateoas-mcp-server',
228
+ version: '1.0.0',
229
+ ...config,
230
+ };
231
+ this.currentUrl = config.entryPoint;
232
+ }
233
+
234
+ /**
235
+ * Register a listener for tool list changes (maps to tools/list_changed).
236
+ */
237
+ onToolsChanged(listener: () => void): void {
238
+ this.toolChangeListeners.push(listener);
239
+ }
240
+
241
+ /**
242
+ * Initialize the server by fetching the entry point entity.
243
+ */
244
+ async initialize(): Promise<void> {
245
+ const entity = await this.fetchEntity(this.config.entryPoint);
246
+ this.updateState(entity, this.config.entryPoint);
247
+ }
248
+
249
+ /**
250
+ * Get the current list of available MCP tools.
251
+ */
252
+ listTools(): McpTool[] {
253
+ return this.currentTools;
254
+ }
255
+
256
+ /**
257
+ * Get the current entity as an MCP resource.
258
+ */
259
+ getCurrentEntity(): SirenEntity | null {
260
+ return this.currentEntity;
261
+ }
262
+
263
+ /**
264
+ * Get the current URL.
265
+ */
266
+ getCurrentUrl(): string {
267
+ return this.currentUrl;
268
+ }
269
+
270
+ /**
271
+ * Call a tool (action or navigation).
272
+ *
273
+ * Returns the result text and triggers tools/list_changed if the
274
+ * entity state changed.
275
+ */
276
+ async callTool(
277
+ name: string,
278
+ args: Record<string, unknown>
279
+ ): Promise<{ content: string; isError?: boolean }> {
280
+ if (!this.currentEntity) {
281
+ return { content: 'Server not initialized. Call initialize() first.', isError: true };
282
+ }
283
+
284
+ // Parse tool name
285
+ const [prefix, ...rest] = name.split('__');
286
+ const identifier = rest.join('__'); // Handle names with double underscores
287
+
288
+ try {
289
+ switch (prefix) {
290
+ case 'action': {
291
+ const action = this.currentEntity.actions?.find(a => a.name === identifier);
292
+ if (!action) {
293
+ return {
294
+ content: `Action "${identifier}" not available. Available: ${(this.currentEntity.actions ?? []).map(a => a.name).join(', ')}`,
295
+ isError: true,
296
+ };
297
+ }
298
+ const result = await this.executeAction(action, args);
299
+ return { content: JSON.stringify(result.properties ?? result, null, 2) };
300
+ }
301
+
302
+ case 'navigate': {
303
+ const link = this.currentEntity.links?.find(l => l.rel.includes(identifier));
304
+ if (!link) {
305
+ return {
306
+ content: `Link "${identifier}" not available. Available: ${(this.currentEntity.links ?? []).map(l => l.rel[0]).join(', ')}`,
307
+ isError: true,
308
+ };
309
+ }
310
+ const entity = await this.fetchEntity(link.href);
311
+ this.updateState(entity, link.href);
312
+ return { content: JSON.stringify(entity.properties ?? entity, null, 2) };
313
+ }
314
+
315
+ default:
316
+ return { content: `Unknown tool prefix: ${prefix}`, isError: true };
317
+ }
318
+ } catch (error) {
319
+ return {
320
+ content: `Error: ${error instanceof Error ? error.message : String(error)}`,
321
+ isError: true,
322
+ };
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Get MCP server capabilities for the initialize response.
328
+ */
329
+ getCapabilities() {
330
+ return {
331
+ tools: { listChanged: true },
332
+ resources: {},
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Get MCP server info for the initialize response.
338
+ */
339
+ getServerInfo() {
340
+ return {
341
+ name: this.config.name,
342
+ version: this.config.version,
343
+ };
344
+ }
345
+
346
+ // ===========================================================================
347
+ // Private
348
+ // ===========================================================================
349
+
350
+ private async fetchEntity(url: string): Promise<SirenEntity> {
351
+ const response = await fetch(url, {
352
+ headers: {
353
+ Accept: 'application/vnd.siren+json, application/json',
354
+ ...this.config.headers,
355
+ },
356
+ });
357
+
358
+ if (!response.ok) {
359
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
360
+ }
361
+
362
+ return (await response.json()) as SirenEntity;
363
+ }
364
+
365
+ private async executeAction(
366
+ action: SirenAction,
367
+ data: Record<string, unknown>
368
+ ): Promise<SirenEntity> {
369
+ const method = action.method?.toUpperCase() ?? 'POST';
370
+ const headers: Record<string, string> = {
371
+ Accept: 'application/vnd.siren+json, application/json',
372
+ ...this.config.headers,
373
+ };
374
+
375
+ let body: string | undefined;
376
+ if (method !== 'GET' && method !== 'HEAD') {
377
+ headers['Content-Type'] = action.type ?? 'application/json';
378
+ body = JSON.stringify(data);
379
+ }
380
+
381
+ const response = await fetch(action.href, { method, headers, body });
382
+
383
+ if (!response.ok) {
384
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
385
+ }
386
+
387
+ const entity = (await response.json()) as SirenEntity;
388
+ this.updateState(entity, action.href);
389
+ return entity;
390
+ }
391
+
392
+ private updateState(entity: SirenEntity, url: string): void {
393
+ this.currentEntity = entity;
394
+ this.currentUrl = url;
395
+ this.currentTools = entityToTools(entity);
396
+
397
+ // Notify listeners (maps to MCP tools/list_changed)
398
+ for (const listener of this.toolChangeListeners) {
399
+ listener();
400
+ }
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Create a new MCP workflow server instance.
406
+ */
407
+ export function createMcpWorkflowServer(config: McpWorkflowServerConfig): McpWorkflowServer {
408
+ return new McpWorkflowServer(config);
409
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Workflow Executor — siren-grail adapter
3
+ *
4
+ * Thin adapter that takes a WorkflowSpec (from FlowScript parsing)
5
+ * and executes it against a Siren API using siren-grail's OODAAgent
6
+ * and compileWorkflow().
7
+ *
8
+ * This bridges FlowScript's natural-language parsing to siren-grail's
9
+ * HATEOAS agent runtime.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { executeWorkflow } from '@lokascript/domain-flow/runtime';
14
+ * import { createFlowDSL, toFlowSpec } from '@lokascript/domain-flow';
15
+ * import { toWorkflowSpec } from '@lokascript/domain-flow/generators/workflow-generator';
16
+ *
17
+ * const flow = createFlowDSL();
18
+ * const specs = [
19
+ * toFlowSpec(flow.parse('enter /api', 'en'), 'en'),
20
+ * toFlowSpec(flow.parse('follow orders', 'en'), 'en'),
21
+ * toFlowSpec(flow.parse('perform createOrder with #checkout', 'en'), 'en'),
22
+ * toFlowSpec(flow.parse('capture as orderId', 'en'), 'en'),
23
+ * ];
24
+ *
25
+ * const workflow = toWorkflowSpec(specs);
26
+ * const result = await executeWorkflow(workflow, { verbose: true });
27
+ * ```
28
+ */
29
+
30
+ import type { WorkflowSpec, WorkflowStep } from '../types.js';
31
+
32
+ /**
33
+ * Result from a workflow execution.
34
+ * Maps to siren-grail's OODAResult.
35
+ */
36
+ export interface WorkflowResult {
37
+ status: 'stopped' | 'error' | 'maxSteps';
38
+ reason: string;
39
+ result?: unknown;
40
+ steps: number;
41
+ history: Array<Record<string, unknown>>;
42
+ }
43
+
44
+ /**
45
+ * Options for workflow execution.
46
+ */
47
+ export interface ExecuteWorkflowOptions {
48
+ /** Maximum OODA steps before stopping (default: 50) */
49
+ maxSteps?: number;
50
+ /** HTTP headers to send with all requests */
51
+ headers?: Record<string, string>;
52
+ /** Enable verbose logging (default: false) */
53
+ verbose?: boolean;
54
+ /** Timeout per request in ms (default: 30000) */
55
+ timeout?: number;
56
+ /** Enable auto-pursuit on 409 Conflict responses */
57
+ autoPursue?: boolean;
58
+ /** Callback on each entity navigation */
59
+ onEntity?: (entity: unknown, url: string) => void;
60
+ /** Callback on each decision */
61
+ onDecision?: (decision: unknown) => void;
62
+ }
63
+
64
+ /**
65
+ * Convert a WorkflowSpec to siren-grail's step format.
66
+ *
67
+ * This is a pure-data transformation — no siren-grail dependency required.
68
+ * The step types map 1:1:
69
+ * WorkflowStep.navigate → { type: 'navigate', rel }
70
+ * WorkflowStep.action → { type: 'action', action, data?, dataSource? }
71
+ * WorkflowStep.stop → { type: 'stop', result?, reason? }
72
+ */
73
+ function toSirenSteps(steps: WorkflowStep[]): Array<Record<string, unknown>> {
74
+ return steps.map(step => {
75
+ switch (step.type) {
76
+ case 'navigate':
77
+ return {
78
+ type: 'navigate',
79
+ rel: step.rel,
80
+ ...(step.capture ? { capture: step.capture } : {}),
81
+ };
82
+
83
+ case 'action':
84
+ return {
85
+ type: 'action',
86
+ action: step.action,
87
+ ...(step.data ? { data: step.data } : {}),
88
+ ...(step.dataSource ? { dataSource: step.dataSource } : {}),
89
+ ...(step.capture ? { capture: step.capture } : {}),
90
+ };
91
+
92
+ case 'stop':
93
+ return {
94
+ type: 'stop',
95
+ ...(step.result ? { result: step.result } : {}),
96
+ ...(step.reason ? { reason: step.reason } : {}),
97
+ };
98
+
99
+ default:
100
+ return step as Record<string, unknown>;
101
+ }
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Execute a WorkflowSpec against a Siren API using siren-grail.
107
+ *
108
+ * Requires `siren-agent` to be installed as a peer dependency.
109
+ * Dynamically imports it to avoid hard dependency.
110
+ *
111
+ * @param spec - The compiled WorkflowSpec from toWorkflowSpec()
112
+ * @param options - Execution options (headers, timeout, etc.)
113
+ * @returns The execution result with status, history, and captured values
114
+ *
115
+ * @throws Error if siren-agent is not installed
116
+ */
117
+ export async function executeWorkflow(
118
+ spec: WorkflowSpec,
119
+ options: ExecuteWorkflowOptions = {}
120
+ ): Promise<WorkflowResult> {
121
+ // Dynamic import to keep siren-agent as optional peer dep
122
+ let sirenAgent: {
123
+ OODAAgent: new (
124
+ url: string,
125
+ opts: Record<string, unknown>
126
+ ) => { run(): Promise<WorkflowResult> };
127
+ compileWorkflow: (steps: Array<Record<string, unknown>>) => unknown;
128
+ };
129
+
130
+ try {
131
+ sirenAgent = await import('siren-agent');
132
+ } catch {
133
+ throw new Error(
134
+ 'siren-agent is required for workflow execution. Install it with: npm install siren-agent'
135
+ );
136
+ }
137
+
138
+ const { OODAAgent, compileWorkflow } = sirenAgent;
139
+ const steps = toSirenSteps(spec.steps);
140
+ const decide = compileWorkflow(steps);
141
+
142
+ const agent = new OODAAgent(spec.entryPoint, {
143
+ maxSteps: options.maxSteps ?? 50,
144
+ headers: options.headers,
145
+ verbose: options.verbose ?? false,
146
+ timeout: options.timeout ?? 30000,
147
+ decide,
148
+ ...(options.autoPursue ? { autoPursue: { maxDepth: 5 } } : {}),
149
+ ...(options.onEntity ? { onEntity: options.onEntity } : {}),
150
+ ...(options.onDecision ? { onDecision: options.onDecision } : {}),
151
+ });
152
+
153
+ return agent.run();
154
+ }
155
+
156
+ /**
157
+ * Create a reusable workflow executor bound to specific options.
158
+ *
159
+ * Useful when executing multiple workflows against the same API
160
+ * with shared headers/auth.
161
+ */
162
+ export function createWorkflowExecutor(defaultOptions: ExecuteWorkflowOptions = {}) {
163
+ return {
164
+ execute(spec: WorkflowSpec, overrides?: ExecuteWorkflowOptions): Promise<WorkflowResult> {
165
+ return executeWorkflow(spec, { ...defaultOptions, ...overrides });
166
+ },
167
+ };
168
+ }
169
+
170
+ // Re-export for convenience
171
+ export { toSirenSteps };