0nmcp 1.3.0 → 1.4.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.
package/server.js ADDED
@@ -0,0 +1,272 @@
1
+ // ============================================================
2
+ // 0nMCP — HTTP Server
3
+ // ============================================================
4
+ // Express server exposing MCP over HTTP, REST API endpoints,
5
+ // and webhook receivers for workflow triggers.
6
+ //
7
+ // Routes:
8
+ // POST/GET/DELETE /mcp — MCP protocol over HTTP
9
+ // GET /api/health — Health check
10
+ // POST /api/execute — Natural language execution
11
+ // POST /api/run — Workflow execution
12
+ // GET /api/workflows — List deployed workflows
13
+ // POST /webhooks/:id — Webhook trigger → workflow execution
14
+ // ============================================================
15
+
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
+ import { ConnectionManager } from "./connections.js";
19
+ import { Orchestrator } from "./orchestrator.js";
20
+ import { WorkflowRunner } from "./workflow.js";
21
+ import { registerAllTools } from "./tools.js";
22
+ import { registerCrmTools } from "./crm/index.js";
23
+ import { z } from "zod";
24
+ import {
25
+ verifyStripeSignature,
26
+ verifyCrmSignature,
27
+ verifySlackSignature,
28
+ verifyGitHubSignature,
29
+ verifyShopifySignature,
30
+ verifyHmac,
31
+ } from "./webhooks.js";
32
+
33
+ /**
34
+ * Create a fully configured Express app (for embedding or testing).
35
+ * @returns {{ app: Express, connections, orchestrator, workflowRunner }}
36
+ */
37
+ export async function createApp() {
38
+ // Dynamic import to keep express optional at module level
39
+ let express;
40
+ try {
41
+ // Express is available as transitive dep from MCP SDK
42
+ express = (await import("express")).default;
43
+ } catch {
44
+ throw new Error("express is required for HTTP server mode. Install with: npm install express");
45
+ }
46
+
47
+ const connections = new ConnectionManager();
48
+ const orchestrator = new Orchestrator(connections);
49
+ const workflowRunner = new WorkflowRunner(connections);
50
+
51
+ const app = express();
52
+
53
+ // ── Raw body capture for webhooks (before json parsing) ──
54
+ app.use("/webhooks", express.raw({ type: "*/*" }));
55
+ app.use(express.json());
56
+
57
+ // ── MCP over HTTP ─────────────────────────────────────────
58
+ // Per-session transport map
59
+ const sessions = new Map();
60
+
61
+ app.all("/mcp", async (req, res) => {
62
+ try {
63
+ // Check for existing session
64
+ const sessionId = req.headers["mcp-session-id"];
65
+
66
+ if (req.method === "GET" || req.method === "DELETE") {
67
+ const transport = sessions.get(sessionId);
68
+ if (!transport) {
69
+ res.status(400).json({ error: "No active session" });
70
+ return;
71
+ }
72
+ await transport.handleRequest(req, res);
73
+ if (req.method === "DELETE") sessions.delete(sessionId);
74
+ return;
75
+ }
76
+
77
+ // POST — new or existing session
78
+ if (sessionId && sessions.has(sessionId)) {
79
+ await sessions.get(sessionId).handleRequest(req, res);
80
+ return;
81
+ }
82
+
83
+ // New session
84
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
85
+ const server = new McpServer({ name: "0nMCP", version: "1.4.0" });
86
+ registerAllTools(server, connections, orchestrator, workflowRunner);
87
+ registerCrmTools(server, z);
88
+
89
+ await server.connect(transport);
90
+
91
+ // Store session after connection (transport now has sessionId)
92
+ transport.onclose = () => {
93
+ if (transport.sessionId) sessions.delete(transport.sessionId);
94
+ };
95
+
96
+ await transport.handleRequest(req, res);
97
+
98
+ if (transport.sessionId) {
99
+ sessions.set(transport.sessionId, transport);
100
+ }
101
+ } catch (err) {
102
+ if (!res.headersSent) {
103
+ res.status(500).json({ error: err.message });
104
+ }
105
+ }
106
+ });
107
+
108
+ // ── REST API ──────────────────────────────────────────────
109
+
110
+ app.get("/api/health", (req, res) => {
111
+ res.json({
112
+ status: "ok",
113
+ name: "0nMCP",
114
+ version: "1.4.0",
115
+ uptime: process.uptime(),
116
+ connections: connections.count(),
117
+ workflows: workflowRunner.listWorkflows().length,
118
+ sessions: sessions.size,
119
+ });
120
+ });
121
+
122
+ app.post("/api/execute", async (req, res) => {
123
+ const { task } = req.body;
124
+ if (!task) {
125
+ res.status(400).json({ error: "Missing 'task' in request body" });
126
+ return;
127
+ }
128
+
129
+ try {
130
+ const result = await orchestrator.execute(task);
131
+ res.json(result);
132
+ } catch (err) {
133
+ res.status(500).json({ error: err.message });
134
+ }
135
+ });
136
+
137
+ app.post("/api/run", async (req, res) => {
138
+ const { workflow, inputs } = req.body;
139
+ if (!workflow) {
140
+ res.status(400).json({ error: "Missing 'workflow' in request body" });
141
+ return;
142
+ }
143
+
144
+ try {
145
+ const result = await workflowRunner.run({ workflowPath: workflow, inputs: inputs || {} });
146
+ res.json(result);
147
+ } catch (err) {
148
+ res.status(500).json({ error: err.message });
149
+ }
150
+ });
151
+
152
+ app.get("/api/workflows", (req, res) => {
153
+ try {
154
+ const workflows = workflowRunner.listWorkflows();
155
+ res.json({ count: workflows.length, workflows });
156
+ } catch (err) {
157
+ res.status(500).json({ error: err.message });
158
+ }
159
+ });
160
+
161
+ // ── Webhook Receiver ──────────────────────────────────────
162
+
163
+ app.post("/webhooks/:workflowId", async (req, res) => {
164
+ const { workflowId } = req.params;
165
+
166
+ try {
167
+ // Load the workflow to check trigger config
168
+ const workflows = workflowRunner.listWorkflows();
169
+ const meta = workflows.find(w =>
170
+ w.name === workflowId ||
171
+ w.file === `${workflowId}.0n` ||
172
+ w.file === `${workflowId}.0n.json`
173
+ );
174
+
175
+ if (!meta) {
176
+ res.status(404).json({ error: `Workflow not found: ${workflowId}` });
177
+ return;
178
+ }
179
+
180
+ // Load full workflow for trigger config
181
+ const { readFileSync } = await import("fs");
182
+ const wfData = JSON.parse(readFileSync(meta.path, "utf8"));
183
+ const trigger = wfData.trigger || {};
184
+
185
+ // Verify webhook signature if configured
186
+ if (trigger.config?.verify) {
187
+ const rawBody = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : JSON.stringify(req.body);
188
+ const verified = verifyWebhook(trigger.config.verify, rawBody, req.headers, trigger.config.secret);
189
+
190
+ if (!verified.verified) {
191
+ res.status(401).json({ error: "Webhook signature verification failed", detail: verified.error });
192
+ return;
193
+ }
194
+ }
195
+
196
+ // Parse body if it was captured as raw buffer
197
+ const inputs = Buffer.isBuffer(req.body) ? JSON.parse(req.body.toString("utf8")) : req.body;
198
+
199
+ // Execute workflow with webhook payload as inputs
200
+ const result = await workflowRunner.run({
201
+ workflowPath: meta.path,
202
+ inputs: inputs || {},
203
+ });
204
+
205
+ res.json({
206
+ status: result.success ? "completed" : "failed",
207
+ execution_id: result.executionId,
208
+ workflow: result.workflow,
209
+ steps_executed: result.stepsExecuted,
210
+ duration_ms: result.duration,
211
+ outputs: result.outputs,
212
+ });
213
+ } catch (err) {
214
+ res.status(500).json({ error: err.message });
215
+ }
216
+ });
217
+
218
+ return { app, connections, orchestrator, workflowRunner };
219
+ }
220
+
221
+ /**
222
+ * Dispatch webhook verification based on provider type.
223
+ */
224
+ function verifyWebhook(provider, rawBody, headers, secret) {
225
+ if (!secret) return { verified: true }; // No secret configured = skip verification
226
+
227
+ switch (provider) {
228
+ case "stripe":
229
+ return verifyStripeSignature(rawBody, headers["stripe-signature"] || "", secret);
230
+ case "github":
231
+ return verifyGitHubSignature(rawBody, headers["x-hub-signature-256"] || "", secret);
232
+ case "slack":
233
+ return verifySlackSignature(rawBody, headers["x-slack-signature"] || "", headers["x-slack-request-timestamp"] || "", secret);
234
+ case "shopify":
235
+ return verifyShopifySignature(rawBody, headers["x-shopify-hmac-sha256"] || "", secret);
236
+ case "crm":
237
+ return verifyCrmSignature(rawBody, headers["x-crm-signature"] || "", secret);
238
+ case "generic":
239
+ default:
240
+ return verifyHmac(rawBody, headers["x-webhook-signature"] || headers["x-signature"] || "", secret);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Start the HTTP server.
246
+ * @param {{ port?: number, host?: string }} options
247
+ */
248
+ export async function startServer({ port = 3000, host = "0.0.0.0" } = {}) {
249
+ const { app, connections, orchestrator, workflowRunner } = await createApp();
250
+
251
+ return new Promise((resolve) => {
252
+ const server = app.listen(port, host, () => {
253
+ const addr = server.address();
254
+ console.log(`
255
+ ┌─────────────────────────────────────────────┐
256
+ │ 0nMCP HTTP Server │
257
+ │ │
258
+ │ MCP: http://${host}:${port}/mcp │
259
+ │ Health: http://${host}:${port}/api/health │
260
+ │ Execute: POST /api/execute │
261
+ │ Run: POST /api/run │
262
+ │ Workflows: GET /api/workflows │
263
+ │ Webhooks: POST /webhooks/:id │
264
+ │ │
265
+ │ Connections: ${String(connections.count()).padEnd(3)} services connected │
266
+ │ Workflows: ${String(workflowRunner.listWorkflows().length).padEnd(3)} deployed │
267
+ └─────────────────────────────────────────────┘
268
+ `);
269
+ resolve({ server, app, connections, orchestrator, workflowRunner });
270
+ });
271
+ });
272
+ }
package/tools.js ADDED
@@ -0,0 +1,419 @@
1
+ // ============================================================
2
+ // 0nMCP — Tool Registration
3
+ // ============================================================
4
+ // All MCP tool definitions in one place. Shared by both
5
+ // stdio (index.js) and HTTP (server.js) transports.
6
+ // ============================================================
7
+
8
+ import { z } from "zod";
9
+ import { SERVICE_CATALOG, listServices, getService } from "./catalog.js";
10
+
11
+ /**
12
+ * Register all universal + workflow tools on an MCP server instance.
13
+ *
14
+ * @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
15
+ * @param {import("./connections.js").ConnectionManager} connections
16
+ * @param {import("./orchestrator.js").Orchestrator} orchestrator
17
+ * @param {import("./workflow.js").WorkflowRunner} [workflowRunner]
18
+ */
19
+ export function registerAllTools(server, connections, orchestrator, workflowRunner) {
20
+ // ─── execute ───────────────────────────────────────────
21
+ server.tool(
22
+ "execute",
23
+ `Execute any task using connected services. The AI orchestrator automatically:
24
+ 1. Parses your intent from natural language
25
+ 2. Finds the best services to use
26
+ 3. Creates an execution plan
27
+ 4. Executes all necessary API calls
28
+ 5. Returns results
29
+
30
+ Examples:
31
+ - "Send an email to john@example.com about the meeting tomorrow"
32
+ - "Create a Stripe customer for sarah@test.com"
33
+ - "Post to #sales on Slack: We just closed a deal!"
34
+ - "Get my Stripe balance"
35
+ - "Add a record to Airtable: Name=John, Status=Active"
36
+ - "Send an SMS to +1234567890: Your order shipped"
37
+ - "Create a GitHub issue: Bug in login page"`,
38
+ {
39
+ task: z.string().describe("Natural language description of what you want to accomplish"),
40
+ },
41
+ async ({ task }) => {
42
+ const result = await orchestrator.execute(task);
43
+
44
+ if (result.success) {
45
+ return {
46
+ content: [{
47
+ type: "text",
48
+ text: JSON.stringify({
49
+ status: "completed",
50
+ message: result.message,
51
+ steps_executed: result.details?.stepsExecuted || 0,
52
+ steps_successful: result.details?.stepsSuccessful || 0,
53
+ duration_ms: result.details?.duration || 0,
54
+ services_used: result.details?.servicesUsed || [],
55
+ plan: result.details?.plan || [],
56
+ }, null, 2),
57
+ }],
58
+ };
59
+ } else {
60
+ return {
61
+ content: [{
62
+ type: "text",
63
+ text: JSON.stringify({
64
+ status: "failed",
65
+ error: result.error,
66
+ suggestion: result.suggestion,
67
+ connected_services: result.connected_services,
68
+ }, null, 2),
69
+ }],
70
+ };
71
+ }
72
+ }
73
+ );
74
+
75
+ // ─── connect_service ───────────────────────────────────
76
+ server.tool(
77
+ "connect_service",
78
+ `Connect a service so the orchestrator can use it. Each service requires specific credentials.
79
+
80
+ Examples:
81
+ - Stripe: { "apiKey": "sk_live_..." }
82
+ - SendGrid: { "apiKey": "SG..." }
83
+ - Twilio: { "accountSid": "AC...", "authToken": "..." }
84
+ - Slack: { "botToken": "xoxb-..." }
85
+ - OpenAI: { "apiKey": "sk-..." }
86
+ - GitHub: { "token": "ghp_..." }
87
+ - Notion: { "apiKey": "ntn_..." }
88
+ - Airtable: { "apiKey": "pat..." }
89
+ - CRM: { "access_token": "..." }
90
+ - HubSpot: { "accessToken": "..." }
91
+ - Shopify: { "accessToken": "...", "store": "mystore" }
92
+ - Supabase: { "apiKey": "...", "projectRef": "..." }
93
+ - Gmail: { "access_token": "..." }
94
+ - Google Sheets: { "access_token": "..." }
95
+ - Google Drive: { "access_token": "..." }
96
+ - Jira: { "email": "...", "apiToken": "...", "domain": "mycompany" }
97
+ - Zendesk: { "email": "...", "apiToken": "...", "subdomain": "mycompany" }
98
+ - Mailchimp: { "apiKey": "...-us21" }
99
+ - Zoom: { "access_token": "..." }
100
+ - Microsoft 365: { "access_token": "..." }
101
+ - MongoDB: { "apiKey": "...", "appId": "..." }`,
102
+ {
103
+ service: z.string().describe("Service key (e.g., stripe, sendgrid, twilio, slack, crm, github, notion, airtable, openai, shopify, hubspot, supabase, discord, linear, resend, calendly, google_calendar, gmail, google_sheets, google_drive, jira, zendesk, mailchimp, zoom, microsoft, mongodb)"),
104
+ credentials: z.record(z.string()).describe("Service credentials as key-value pairs"),
105
+ },
106
+ async ({ service, credentials }) => {
107
+ const result = connections.connect(service, credentials);
108
+
109
+ if (result.success) {
110
+ return {
111
+ content: [{
112
+ type: "text",
113
+ text: JSON.stringify({
114
+ status: "connected",
115
+ service: result.service.name,
116
+ capabilities: result.service.capabilities,
117
+ message: `Connected to ${result.service.name}. You now have ${result.service.capabilities} capabilities available.`,
118
+ }, null, 2),
119
+ }],
120
+ };
121
+ } else {
122
+ return {
123
+ content: [{
124
+ type: "text",
125
+ text: JSON.stringify({ status: "failed", error: result.error }, null, 2),
126
+ }],
127
+ };
128
+ }
129
+ }
130
+ );
131
+
132
+ // ─── disconnect_service ────────────────────────────────
133
+ server.tool(
134
+ "disconnect_service",
135
+ "Disconnect a connected service. Removes stored credentials.",
136
+ {
137
+ service: z.string().describe("Service key to disconnect (e.g., stripe, sendgrid)"),
138
+ },
139
+ async ({ service }) => {
140
+ const result = connections.disconnect(service);
141
+ return {
142
+ content: [{
143
+ type: "text",
144
+ text: JSON.stringify({
145
+ status: result.success ? "disconnected" : "failed",
146
+ error: result.error,
147
+ }, null, 2),
148
+ }],
149
+ };
150
+ }
151
+ );
152
+
153
+ // ─── list_connections ──────────────────────────────────
154
+ server.tool(
155
+ "list_connections",
156
+ "List all connected services, their types, and capability counts.",
157
+ {},
158
+ async () => {
159
+ const connected = connections.list();
160
+
161
+ if (connected.length === 0) {
162
+ return {
163
+ content: [{
164
+ type: "text",
165
+ text: JSON.stringify({
166
+ count: 0,
167
+ services: [],
168
+ message: "No services connected. Use connect_service to add integrations.",
169
+ }, null, 2),
170
+ }],
171
+ };
172
+ }
173
+
174
+ return {
175
+ content: [{
176
+ type: "text",
177
+ text: JSON.stringify({
178
+ count: connected.length,
179
+ services: connected,
180
+ }, null, 2),
181
+ }],
182
+ };
183
+ }
184
+ );
185
+
186
+ // ─── list_available_services ───────────────────────────
187
+ server.tool(
188
+ "list_available_services",
189
+ "List all services that can be connected, grouped by category.",
190
+ {},
191
+ async () => {
192
+ const services = listServices();
193
+ const connected = new Set(connections.keys());
194
+
195
+ const grouped = {};
196
+ for (const svc of services) {
197
+ if (!grouped[svc.type]) grouped[svc.type] = [];
198
+ grouped[svc.type].push({
199
+ key: svc.key,
200
+ name: svc.name,
201
+ description: svc.description,
202
+ capabilities: svc.capabilityCount,
203
+ authType: svc.authType,
204
+ credentialKeys: svc.credentialKeys,
205
+ connected: connected.has(svc.key),
206
+ });
207
+ }
208
+
209
+ return {
210
+ content: [{
211
+ type: "text",
212
+ text: JSON.stringify({
213
+ total: services.length,
214
+ connected: connected.size,
215
+ services: grouped,
216
+ }, null, 2),
217
+ }],
218
+ };
219
+ }
220
+ );
221
+
222
+ // ─── get_service_info ──────────────────────────────────
223
+ server.tool(
224
+ "get_service_info",
225
+ "Get detailed information about a specific service — capabilities, endpoints, and required credentials.",
226
+ {
227
+ service: z.string().describe("Service key (e.g., stripe, crm)"),
228
+ },
229
+ async ({ service }) => {
230
+ const catalog = getService(service);
231
+ if (!catalog) {
232
+ return {
233
+ content: [{
234
+ type: "text",
235
+ text: JSON.stringify({ error: `Unknown service: ${service}`, available: listServices().map(s => s.key) }, null, 2),
236
+ }],
237
+ };
238
+ }
239
+
240
+ return {
241
+ content: [{
242
+ type: "text",
243
+ text: JSON.stringify({
244
+ key: service,
245
+ name: catalog.name,
246
+ type: catalog.type,
247
+ description: catalog.description,
248
+ authType: catalog.authType,
249
+ credentialKeys: catalog.credentialKeys,
250
+ connected: connections.isConnected(service),
251
+ capabilities: catalog.capabilities,
252
+ endpoints: Object.entries(catalog.endpoints).map(([key, ep]) => ({
253
+ name: key,
254
+ method: ep.method,
255
+ path: ep.path,
256
+ })),
257
+ }, null, 2),
258
+ }],
259
+ };
260
+ }
261
+ );
262
+
263
+ // ─── api_call ──────────────────────────────────────────
264
+ server.tool(
265
+ "api_call",
266
+ "Make a direct API call to any connected service. For advanced use when you need fine-grained control beyond the execute tool.",
267
+ {
268
+ service: z.string().describe("Service key (e.g., stripe, sendgrid)"),
269
+ endpoint: z.string().describe("Endpoint name from the service catalog"),
270
+ params: z.record(z.any()).optional().describe("Parameters for the API call"),
271
+ },
272
+ async ({ service, endpoint, params }) => {
273
+ const catalog = getService(service);
274
+ if (!catalog) {
275
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown service: ${service}` }, null, 2) }] };
276
+ }
277
+
278
+ const ep = catalog.endpoints[endpoint];
279
+ if (!ep) {
280
+ return {
281
+ content: [{
282
+ type: "text",
283
+ text: JSON.stringify({ error: `Unknown endpoint: ${endpoint}`, available: Object.keys(catalog.endpoints) }, null, 2),
284
+ }],
285
+ };
286
+ }
287
+
288
+ const creds = connections.getCredentials(service);
289
+ if (!creds) {
290
+ return { content: [{ type: "text", text: JSON.stringify({ error: `Service ${service} not connected` }, null, 2) }] };
291
+ }
292
+
293
+ try {
294
+ let url = catalog.baseUrl + ep.path;
295
+ const allParams = { ...creds, ...(params || {}) };
296
+
297
+ // Substitute path params
298
+ url = url.replace(/\{(\w+)\}/g, (_, key) => allParams[key] || `{${key}}`);
299
+
300
+ const headers = catalog.authHeader(creds);
301
+ const options = { method: ep.method, headers };
302
+
303
+ if (ep.method !== "GET" && params) {
304
+ const contentType = ep.contentType || "application/json";
305
+ if (contentType === "application/x-www-form-urlencoded") {
306
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
307
+ const flat = {};
308
+ for (const [k, v] of Object.entries(params)) {
309
+ if (typeof v !== "object") flat[k] = String(v);
310
+ }
311
+ options.body = new URLSearchParams(flat).toString();
312
+ } else {
313
+ headers["Content-Type"] = "application/json";
314
+ options.body = JSON.stringify(params);
315
+ }
316
+ }
317
+
318
+ if (ep.method === "GET" && params) {
319
+ const flat = {};
320
+ for (const [k, v] of Object.entries(params)) {
321
+ if (typeof v !== "object") flat[k] = String(v);
322
+ }
323
+ const qs = new URLSearchParams(flat).toString();
324
+ if (qs) url += (url.includes("?") ? "&" : "?") + qs;
325
+ }
326
+
327
+ const response = await fetch(url, options);
328
+ const data = await response.json().catch(() => ({ status: response.status, statusText: response.statusText }));
329
+
330
+ return {
331
+ content: [{
332
+ type: "text",
333
+ text: JSON.stringify({ success: response.ok, status: response.status, data }, null, 2),
334
+ }],
335
+ };
336
+ } catch (err) {
337
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }, null, 2) }] };
338
+ }
339
+ }
340
+ );
341
+
342
+ // ============================================================
343
+ // WORKFLOW TOOLS (only if WorkflowRunner is available)
344
+ // ============================================================
345
+
346
+ if (workflowRunner) {
347
+ // ─── run_workflow ──────────────────────────────────────
348
+ server.tool(
349
+ "run_workflow",
350
+ `Execute a pre-defined .0n workflow file. Workflows are deterministic, step-by-step automations stored in ~/.0n/workflows/.
351
+
352
+ Unlike the 'execute' tool (which uses AI to interpret natural language), run_workflow executes a specific, pre-built automation with defined inputs and steps.
353
+
354
+ Examples:
355
+ - run_workflow({ workflow: "invoice-notify", inputs: { customer_email: "test@x.com", amount: 100 } })
356
+ - run_workflow({ workflow: "lead-scoring", inputs: { contactSource: "google", projectType: "kitchen" } })`,
357
+ {
358
+ workflow: z.string().describe("Workflow name (without .0n extension) or full file path"),
359
+ inputs: z.record(z.any()).optional().describe("Input values for the workflow"),
360
+ },
361
+ async ({ workflow, inputs }) => {
362
+ try {
363
+ const result = await workflowRunner.run({ workflowPath: workflow, inputs: inputs || {} });
364
+
365
+ return {
366
+ content: [{
367
+ type: "text",
368
+ text: JSON.stringify({
369
+ status: result.success ? "completed" : "failed",
370
+ workflow: result.workflow,
371
+ execution_id: result.executionId,
372
+ steps_executed: result.stepsExecuted,
373
+ steps_successful: result.stepsSuccessful,
374
+ duration_ms: result.duration,
375
+ outputs: result.outputs,
376
+ errors: result.errors.length > 0 ? result.errors : undefined,
377
+ }, null, 2),
378
+ }],
379
+ };
380
+ } catch (err) {
381
+ return {
382
+ content: [{
383
+ type: "text",
384
+ text: JSON.stringify({ status: "failed", error: err.message }, null, 2),
385
+ }],
386
+ };
387
+ }
388
+ }
389
+ );
390
+
391
+ // ─── list_workflows ────────────────────────────────────
392
+ server.tool(
393
+ "list_workflows",
394
+ "List all .0n workflow files deployed to ~/.0n/workflows/.",
395
+ {},
396
+ async () => {
397
+ try {
398
+ const workflows = workflowRunner.listWorkflows();
399
+ return {
400
+ content: [{
401
+ type: "text",
402
+ text: JSON.stringify({
403
+ count: workflows.length,
404
+ workflows,
405
+ }, null, 2),
406
+ }],
407
+ };
408
+ } catch (err) {
409
+ return {
410
+ content: [{
411
+ type: "text",
412
+ text: JSON.stringify({ error: err.message }, null, 2),
413
+ }],
414
+ };
415
+ }
416
+ }
417
+ );
418
+ }
419
+ }