@executioncontrolprotocol/mcp 0.10.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/package.json +34 -0
- package/src/index.ts +436 -0
- package/test/integration/mcp-server.test.ts +158 -0
- package/tsconfig.json +6 -0
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@executioncontrolprotocol/mcp",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "MCP server adapter for ECP environments",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -b"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@executioncontrolprotocol/core": "^0.10.0",
|
|
20
|
+
"@executioncontrolprotocol/types": "^0.10.0",
|
|
21
|
+
"zod": "^3.24.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@executioncontrolprotocol/node": "0.10.0",
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@executioncontrolprotocol/core": "0.10.0",
|
|
29
|
+
"@executioncontrolprotocol/format-toon": "0.10.0",
|
|
30
|
+
"@executioncontrolprotocol/types": "0.10.0",
|
|
31
|
+
"typescript": "^5.7.0",
|
|
32
|
+
"zod": "^3.24.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
|
|
4
|
+
import { z } from "zod"
|
|
5
|
+
import type { Ecp } from "@executioncontrolprotocol/core"
|
|
6
|
+
import type { EcpEncodeInput, RunResult, WorkflowManifest } from "@executioncontrolprotocol/types"
|
|
7
|
+
import { ECP_FORMATS } from "@executioncontrolprotocol/types"
|
|
8
|
+
import { createServer, type Server } from "node:http"
|
|
9
|
+
|
|
10
|
+
/** Options for MCP server creation. @category MCP */
|
|
11
|
+
export interface CreateEcpMcpServerOptions {
|
|
12
|
+
/** Initialized operational ECP instance. */
|
|
13
|
+
ecp: Ecp
|
|
14
|
+
name?: string
|
|
15
|
+
version?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** JSON mime type used for ECP MCP resources. */
|
|
19
|
+
const JSON_MIME = "application/json"
|
|
20
|
+
|
|
21
|
+
/** In-process run store shared across MCP server instances in this process. */
|
|
22
|
+
const runs = new Map<string, RunResult>()
|
|
23
|
+
|
|
24
|
+
/** Wrap a value as an MCP tool text result. */
|
|
25
|
+
function toolText(value: unknown): { content: { type: "text"; text: string }[] } {
|
|
26
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Wrap a value as MCP resource JSON contents. */
|
|
30
|
+
function jsonContents(uri: URL, value: unknown) {
|
|
31
|
+
return {
|
|
32
|
+
contents: [
|
|
33
|
+
{ uri: uri.href, mimeType: JSON_MIME, text: JSON.stringify(value, null, 2) },
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Register operational tools on the server. */
|
|
39
|
+
function registerEcpTools(server: McpServer, ecp: Ecp): void {
|
|
40
|
+
server.tool(
|
|
41
|
+
"ecp.describe_environment",
|
|
42
|
+
{ query: z.record(z.unknown()).optional() },
|
|
43
|
+
async ({ query }) => toolText(await ecp.describe(query))
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
server.tool(
|
|
47
|
+
"ecp.search",
|
|
48
|
+
{ query: z.string(), options: z.record(z.unknown()).optional() },
|
|
49
|
+
async ({ query, options: searchOpts }) =>
|
|
50
|
+
toolText(await ecp.search(query, searchOpts as import("@executioncontrolprotocol/types").SearchOptions))
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
server.tool(
|
|
54
|
+
"ecp.validate_workflow",
|
|
55
|
+
{ workflow: z.record(z.unknown()) },
|
|
56
|
+
async ({ workflow }) =>
|
|
57
|
+
toolText(await ecp.validate(workflow as unknown as WorkflowManifest))
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
server.tool(
|
|
61
|
+
"ecp.run_workflow",
|
|
62
|
+
{
|
|
63
|
+
workflow: z.record(z.unknown()),
|
|
64
|
+
input: z.record(z.unknown()).optional(),
|
|
65
|
+
dryRun: z.boolean().optional(),
|
|
66
|
+
},
|
|
67
|
+
async ({ workflow, input, dryRun }) => {
|
|
68
|
+
const result = await ecp.run(workflow as unknown as WorkflowManifest, {
|
|
69
|
+
input,
|
|
70
|
+
dryRun,
|
|
71
|
+
})
|
|
72
|
+
runs.set(result.run.id, result)
|
|
73
|
+
return toolText(result)
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const formatSchema = z.enum(["json", "toon", "fluent"])
|
|
78
|
+
|
|
79
|
+
server.tool(
|
|
80
|
+
"ecp.encode",
|
|
81
|
+
{
|
|
82
|
+
source: z.union([z.record(z.unknown()), z.string()]),
|
|
83
|
+
format: formatSchema.optional(),
|
|
84
|
+
compact: z.boolean().optional(),
|
|
85
|
+
},
|
|
86
|
+
async ({ source, format, compact }) => {
|
|
87
|
+
let op = ecp.encode(source as EcpEncodeInput["source"])
|
|
88
|
+
if (format === "toon") op = op.uses("@executioncontrolprotocol/format-toon")
|
|
89
|
+
else if (format === "fluent") op = op.uses("@executioncontrolprotocol/format-fluent")
|
|
90
|
+
else op = op.uses("@executioncontrolprotocol/format-json")
|
|
91
|
+
if (compact) op = op.compact()
|
|
92
|
+
return toolText(await op.process())
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
server.tool(
|
|
97
|
+
"ecp.decode",
|
|
98
|
+
{
|
|
99
|
+
content: z.union([z.record(z.unknown()), z.string()]),
|
|
100
|
+
format: z.enum(["json", "toon"]).optional(),
|
|
101
|
+
strict: z.boolean().optional(),
|
|
102
|
+
targetSchema: z.string().optional(),
|
|
103
|
+
},
|
|
104
|
+
async ({ content, format, strict, targetSchema }) => {
|
|
105
|
+
let op = ecp.decode(content)
|
|
106
|
+
if (format === "toon") op = op.uses("@executioncontrolprotocol/format-toon")
|
|
107
|
+
else op = op.uses("@executioncontrolprotocol/format-json")
|
|
108
|
+
if (strict) op = op.strict()
|
|
109
|
+
if (targetSchema) op = op.to(targetSchema as "@executioncontrolprotocol.workflow")
|
|
110
|
+
else if (!format || format === ECP_FORMATS.JSON) op = op.to("@executioncontrolprotocol.workflow")
|
|
111
|
+
return toolText(await op.process())
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
server.tool("ecp.get_run_status", { runId: z.string() }, async ({ runId }) =>
|
|
116
|
+
toolText(runs.get(runId) ?? { error: "Run not found", runId })
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Register discovery resources on the server. */
|
|
121
|
+
function registerEcpResources(server: McpServer, ecp: Ecp): void {
|
|
122
|
+
server.registerResource(
|
|
123
|
+
"environment",
|
|
124
|
+
"ecp://environment/describe",
|
|
125
|
+
{
|
|
126
|
+
title: "Environment descriptor",
|
|
127
|
+
description: "Full ECP environment descriptor: runtime, extensions, capabilities, policies.",
|
|
128
|
+
mimeType: JSON_MIME,
|
|
129
|
+
},
|
|
130
|
+
async (uri) => jsonContents(uri, await ecp.describe())
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
server.registerResource(
|
|
134
|
+
"capabilities",
|
|
135
|
+
"ecp://capabilities",
|
|
136
|
+
{
|
|
137
|
+
title: "Capabilities",
|
|
138
|
+
description: "All capabilities available in this environment.",
|
|
139
|
+
mimeType: JSON_MIME,
|
|
140
|
+
},
|
|
141
|
+
async (uri) => jsonContents(uri, (await ecp.describe()).capabilities)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
server.registerResource(
|
|
145
|
+
"policies",
|
|
146
|
+
"ecp://policies",
|
|
147
|
+
{
|
|
148
|
+
title: "Policies",
|
|
149
|
+
description: "All policies governing this environment.",
|
|
150
|
+
mimeType: JSON_MIME,
|
|
151
|
+
},
|
|
152
|
+
async (uri) => jsonContents(uri, (await ecp.describe()).policies)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// Reserved expansion `{+id}` so capability ids containing `/` and `.`
|
|
156
|
+
// (e.g. `@executioncontrolprotocol/test.echo`) match and round-trip correctly.
|
|
157
|
+
server.registerResource(
|
|
158
|
+
"capability",
|
|
159
|
+
new ResourceTemplate("ecp://capabilities/{+id}", {
|
|
160
|
+
list: async () => {
|
|
161
|
+
const descriptor = await ecp.describe()
|
|
162
|
+
return {
|
|
163
|
+
resources: descriptor.capabilities.map((c) => ({
|
|
164
|
+
uri: `ecp://capabilities/${c.id}`,
|
|
165
|
+
name: c.id,
|
|
166
|
+
...(c.label ? { title: c.label } : {}),
|
|
167
|
+
mimeType: JSON_MIME,
|
|
168
|
+
})),
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
{
|
|
173
|
+
title: "Capability detail",
|
|
174
|
+
description: "A single capability's schemas and metadata by id.",
|
|
175
|
+
mimeType: JSON_MIME,
|
|
176
|
+
},
|
|
177
|
+
async (uri, variables) => {
|
|
178
|
+
const id = String(variables.id)
|
|
179
|
+
const descriptor = await ecp.describe()
|
|
180
|
+
const capability = descriptor.capabilities.find((c) => c.id === id)
|
|
181
|
+
return jsonContents(uri, capability ?? { error: "Capability not found", id })
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
server.registerResource(
|
|
186
|
+
"run",
|
|
187
|
+
new ResourceTemplate("ecp://runs/{runId}", {
|
|
188
|
+
list: async () => ({
|
|
189
|
+
resources: [...runs.keys()].map((id) => ({
|
|
190
|
+
uri: `ecp://runs/${id}`,
|
|
191
|
+
name: id,
|
|
192
|
+
mimeType: JSON_MIME,
|
|
193
|
+
})),
|
|
194
|
+
}),
|
|
195
|
+
}),
|
|
196
|
+
{
|
|
197
|
+
title: "Run detail",
|
|
198
|
+
description: "Result of a workflow run executed via ecp.run_workflow in this process.",
|
|
199
|
+
mimeType: JSON_MIME,
|
|
200
|
+
},
|
|
201
|
+
async (uri, variables) => {
|
|
202
|
+
const runId = String(variables.runId)
|
|
203
|
+
return jsonContents(uri, runs.get(runId) ?? { error: "Run not found", runId })
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Format the environment's capabilities as a bullet list for prompts. */
|
|
209
|
+
function capabilityList(
|
|
210
|
+
capabilities: { id: string; label?: string }[]
|
|
211
|
+
): string {
|
|
212
|
+
if (capabilities.length === 0) return "(no capabilities are registered)"
|
|
213
|
+
return capabilities
|
|
214
|
+
.map((c) => `- ${c.id}${c.label ? ` — ${c.label}` : ""}`)
|
|
215
|
+
.join("\n")
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Register guidance prompts on the server. */
|
|
219
|
+
function registerEcpPrompts(server: McpServer, ecp: Ecp): void {
|
|
220
|
+
server.registerPrompt(
|
|
221
|
+
"ecp.author_workflow",
|
|
222
|
+
{
|
|
223
|
+
title: "Author an ECP workflow",
|
|
224
|
+
description: "Guide the agent to build a valid @executioncontrolprotocol.workflow manifest for a goal.",
|
|
225
|
+
argsSchema: { goal: z.string() },
|
|
226
|
+
},
|
|
227
|
+
async ({ goal }) => {
|
|
228
|
+
const descriptor = await ecp.describe()
|
|
229
|
+
return {
|
|
230
|
+
messages: [
|
|
231
|
+
{
|
|
232
|
+
role: "user",
|
|
233
|
+
content: {
|
|
234
|
+
type: "text",
|
|
235
|
+
text: [
|
|
236
|
+
`Author an ECP workflow manifest (schema "@executioncontrolprotocol.workflow", version "1.0") for this goal:`,
|
|
237
|
+
"",
|
|
238
|
+
goal,
|
|
239
|
+
"",
|
|
240
|
+
"Only use capabilities available in this environment:",
|
|
241
|
+
capabilityList(descriptor.capabilities),
|
|
242
|
+
"",
|
|
243
|
+
"Rules:",
|
|
244
|
+
"- Output a single JSON workflow manifest.",
|
|
245
|
+
"- Each step's `uses` must be one of the capability ids above.",
|
|
246
|
+
"- Use `as` to commit a step output to state; reference prior state with { \"$ref\": \"state.<key>\" }.",
|
|
247
|
+
"- Do not include runtime, extension, policy, or secret configuration.",
|
|
248
|
+
"Then call ecp.validate_workflow to confirm it is valid before running.",
|
|
249
|
+
].join("\n"),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
server.registerPrompt(
|
|
258
|
+
"ecp.repair_workflow",
|
|
259
|
+
{
|
|
260
|
+
title: "Repair an ECP workflow",
|
|
261
|
+
description: "Guide the agent to fix a workflow using validation errors.",
|
|
262
|
+
argsSchema: { workflow: z.string(), errors: z.string().optional() },
|
|
263
|
+
},
|
|
264
|
+
({ workflow, errors }) => ({
|
|
265
|
+
messages: [
|
|
266
|
+
{
|
|
267
|
+
role: "user",
|
|
268
|
+
content: {
|
|
269
|
+
type: "text",
|
|
270
|
+
text: [
|
|
271
|
+
"Fix this ECP workflow manifest so it validates against the environment.",
|
|
272
|
+
"",
|
|
273
|
+
"Workflow:",
|
|
274
|
+
workflow,
|
|
275
|
+
"",
|
|
276
|
+
"Validation errors:",
|
|
277
|
+
errors && errors.trim().length > 0
|
|
278
|
+
? errors
|
|
279
|
+
: "(none provided — call ecp.validate_workflow to obtain them)",
|
|
280
|
+
"",
|
|
281
|
+
"Return the corrected JSON workflow manifest only, then re-validate.",
|
|
282
|
+
].join("\n"),
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
})
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
server.registerPrompt(
|
|
290
|
+
"ecp.explain_environment",
|
|
291
|
+
{
|
|
292
|
+
title: "Explain the environment",
|
|
293
|
+
description: "Summarize the capabilities and policies available in this environment.",
|
|
294
|
+
},
|
|
295
|
+
async () => {
|
|
296
|
+
const descriptor = await ecp.describe()
|
|
297
|
+
const policies =
|
|
298
|
+
descriptor.policies.length > 0
|
|
299
|
+
? descriptor.policies
|
|
300
|
+
.map((p) => `- ${p.id}${p.summary ? ` — ${p.summary}` : ""}`)
|
|
301
|
+
.join("\n")
|
|
302
|
+
: "(no policies are bound)"
|
|
303
|
+
return {
|
|
304
|
+
messages: [
|
|
305
|
+
{
|
|
306
|
+
role: "user",
|
|
307
|
+
content: {
|
|
308
|
+
type: "text",
|
|
309
|
+
text: [
|
|
310
|
+
`Explain what this ECP environment ("${descriptor.environment.id}") can do.`,
|
|
311
|
+
"",
|
|
312
|
+
"Capabilities:",
|
|
313
|
+
capabilityList(descriptor.capabilities),
|
|
314
|
+
"",
|
|
315
|
+
"Policies:",
|
|
316
|
+
policies,
|
|
317
|
+
].join("\n"),
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Create an MCP server exposing ECP operational APIs as tools, resources, and prompts.
|
|
328
|
+
* @category MCP
|
|
329
|
+
*/
|
|
330
|
+
export function createEcpMcpServer(options: CreateEcpMcpServerOptions): McpServer {
|
|
331
|
+
const { ecp } = options
|
|
332
|
+
const server = new McpServer({
|
|
333
|
+
name: options.name ?? "ecp",
|
|
334
|
+
version: options.version ?? "1.0.0",
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
registerEcpTools(server, ecp)
|
|
338
|
+
registerEcpResources(server, ecp)
|
|
339
|
+
registerEcpPrompts(server, ecp)
|
|
340
|
+
|
|
341
|
+
return server
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Serve MCP over stdio. @category MCP */
|
|
345
|
+
export async function serveStdio(options: {
|
|
346
|
+
environment: import("@executioncontrolprotocol/core").Environment
|
|
347
|
+
name?: string
|
|
348
|
+
version?: string
|
|
349
|
+
}): Promise<void> {
|
|
350
|
+
const ecp = await options.environment.init()
|
|
351
|
+
const server = createEcpMcpServer({ ecp, name: options.name, version: options.version })
|
|
352
|
+
const transport = new StdioServerTransport()
|
|
353
|
+
await server.connect(transport)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Default MCP endpoint path for {@link serveHttp}. */
|
|
357
|
+
const DEFAULT_MCP_PATH = "/mcp"
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Serve MCP over Streamable HTTP.
|
|
361
|
+
*
|
|
362
|
+
* Implements the MCP Streamable HTTP transport in stateless mode: each POST to
|
|
363
|
+
* the MCP endpoint gets a fresh {@link McpServer} + transport bound to the same
|
|
364
|
+
* initialized {@link Ecp}, avoiding request-id collisions between concurrent
|
|
365
|
+
* clients. Returns the underlying `http.Server` so callers can close it.
|
|
366
|
+
*
|
|
367
|
+
* @category MCP
|
|
368
|
+
*/
|
|
369
|
+
export async function serveHttp(options: {
|
|
370
|
+
environment: import("@executioncontrolprotocol/core").Environment
|
|
371
|
+
port?: number
|
|
372
|
+
path?: string
|
|
373
|
+
name?: string
|
|
374
|
+
version?: string
|
|
375
|
+
}): Promise<Server> {
|
|
376
|
+
const ecp = await options.environment.init()
|
|
377
|
+
const port = options.port ?? 8787
|
|
378
|
+
const path = options.path ?? DEFAULT_MCP_PATH
|
|
379
|
+
|
|
380
|
+
const httpServer = createServer((req, res) => {
|
|
381
|
+
void (async () => {
|
|
382
|
+
const url = req.url ?? ""
|
|
383
|
+
const pathname = url.split("?")[0]
|
|
384
|
+
if (pathname !== path) {
|
|
385
|
+
res.writeHead(404, { "Content-Type": JSON_MIME })
|
|
386
|
+
res.end(JSON.stringify({ error: "Not found" }))
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (req.method !== "POST") {
|
|
391
|
+
// Stateless mode: no server-initiated streams, so GET/DELETE are
|
|
392
|
+
// not supported. Mirror the MCP SDK's stateless guidance.
|
|
393
|
+
res.writeHead(405, { "Content-Type": JSON_MIME, Allow: "POST" })
|
|
394
|
+
res.end(
|
|
395
|
+
JSON.stringify({
|
|
396
|
+
jsonrpc: "2.0",
|
|
397
|
+
error: { code: -32000, message: "Method not allowed. Use POST." },
|
|
398
|
+
id: null,
|
|
399
|
+
})
|
|
400
|
+
)
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const server = createEcpMcpServer({
|
|
405
|
+
ecp,
|
|
406
|
+
name: options.name,
|
|
407
|
+
version: options.version,
|
|
408
|
+
})
|
|
409
|
+
const transport = new StreamableHTTPServerTransport({
|
|
410
|
+
sessionIdGenerator: undefined,
|
|
411
|
+
})
|
|
412
|
+
res.on("close", () => {
|
|
413
|
+
void transport.close()
|
|
414
|
+
void server.close()
|
|
415
|
+
})
|
|
416
|
+
try {
|
|
417
|
+
await server.connect(transport)
|
|
418
|
+
await transport.handleRequest(req, res)
|
|
419
|
+
} catch {
|
|
420
|
+
if (!res.headersSent) {
|
|
421
|
+
res.writeHead(500, { "Content-Type": JSON_MIME })
|
|
422
|
+
res.end(
|
|
423
|
+
JSON.stringify({
|
|
424
|
+
jsonrpc: "2.0",
|
|
425
|
+
error: { code: -32603, message: "Internal server error" },
|
|
426
|
+
id: null,
|
|
427
|
+
})
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
})()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
await new Promise<void>((resolve) => httpServer.listen(port, resolve))
|
|
435
|
+
return httpServer
|
|
436
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
|
4
|
+
import { createEcpMcpServer } from "../../src/index.js"
|
|
5
|
+
import { extension, workflow, step, registerTestExtension } from "@executioncontrolprotocol/core"
|
|
6
|
+
import { environment } from "@executioncontrolprotocol/node"
|
|
7
|
+
import type { Ecp } from "@executioncontrolprotocol/core"
|
|
8
|
+
|
|
9
|
+
type TextContent = { type: string; text: string }
|
|
10
|
+
|
|
11
|
+
/** Parse the first text content block of a tool/resource result as JSON. */
|
|
12
|
+
function parseText(content: TextContent[] | undefined): unknown {
|
|
13
|
+
const text = content?.find((c) => c.type === "text")?.text
|
|
14
|
+
if (text === undefined) throw new Error("no text content")
|
|
15
|
+
return JSON.parse(text)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const echoWorkflow = workflow("Echo")
|
|
19
|
+
.run([step("@executioncontrolprotocol/test.echo", "Echo").with({ value: "hi" }).as("out")])
|
|
20
|
+
.toManifest()
|
|
21
|
+
|
|
22
|
+
describe("@executioncontrolprotocol/mcp wire protocol (Client <-> Server)", () => {
|
|
23
|
+
let ecp: Ecp
|
|
24
|
+
let client: Client
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
await registerTestExtension()
|
|
28
|
+
const env = (await environment("mcp-wire")).withExtensions([
|
|
29
|
+
extension("@executioncontrolprotocol/test", "Test").with({}),
|
|
30
|
+
])
|
|
31
|
+
ecp = await env.init()
|
|
32
|
+
|
|
33
|
+
const server = createEcpMcpServer({ ecp })
|
|
34
|
+
client = new Client({ name: "test-client", version: "1.0.0" })
|
|
35
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
36
|
+
await Promise.all([
|
|
37
|
+
server.connect(serverTransport),
|
|
38
|
+
client.connect(clientTransport),
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await client.close()
|
|
44
|
+
await ecp.terminate()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("lists the operational tools", async () => {
|
|
48
|
+
const { tools } = await client.listTools()
|
|
49
|
+
const names = tools.map((t) => t.name)
|
|
50
|
+
expect(names).toEqual(
|
|
51
|
+
expect.arrayContaining([
|
|
52
|
+
"ecp.describe_environment",
|
|
53
|
+
"ecp.search",
|
|
54
|
+
"ecp.validate_workflow",
|
|
55
|
+
"ecp.run_workflow",
|
|
56
|
+
"ecp.encode",
|
|
57
|
+
"ecp.decode",
|
|
58
|
+
"ecp.get_run_status",
|
|
59
|
+
])
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("describes the environment over the wire", async () => {
|
|
64
|
+
const res = await client.callTool({ name: "ecp.describe_environment", arguments: {} })
|
|
65
|
+
const descriptor = parseText(res.content as TextContent[]) as {
|
|
66
|
+
schema: string
|
|
67
|
+
capabilities: { id: string }[]
|
|
68
|
+
}
|
|
69
|
+
expect(descriptor.schema).toBe("@executioncontrolprotocol.environment.describe")
|
|
70
|
+
expect(descriptor.capabilities.some((c) => c.id.includes("echo"))).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("validates a workflow over the wire", async () => {
|
|
74
|
+
const res = await client.callTool({
|
|
75
|
+
name: "ecp.validate_workflow",
|
|
76
|
+
arguments: { workflow: echoWorkflow as unknown as Record<string, unknown> },
|
|
77
|
+
})
|
|
78
|
+
const validation = parseText(res.content as TextContent[]) as { valid: boolean }
|
|
79
|
+
expect(validation.valid).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("reports validation failure for an unknown capability", async () => {
|
|
83
|
+
const broken = workflow("Broken")
|
|
84
|
+
.run([step("@executioncontrolprotocol/test.does-not-exist", "X").with({}).as("o")])
|
|
85
|
+
.toManifest()
|
|
86
|
+
const res = await client.callTool({
|
|
87
|
+
name: "ecp.validate_workflow",
|
|
88
|
+
arguments: { workflow: broken as unknown as Record<string, unknown> },
|
|
89
|
+
})
|
|
90
|
+
const validation = parseText(res.content as TextContent[]) as { valid: boolean }
|
|
91
|
+
expect(validation.valid).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("runs a workflow and retrieves its status", async () => {
|
|
95
|
+
const runRes = await client.callTool({
|
|
96
|
+
name: "ecp.run_workflow",
|
|
97
|
+
arguments: { workflow: echoWorkflow as unknown as Record<string, unknown> },
|
|
98
|
+
})
|
|
99
|
+
const result = parseText(runRes.content as TextContent[]) as {
|
|
100
|
+
run: { id: string; status: string }
|
|
101
|
+
}
|
|
102
|
+
expect(result.run.status).toBe("completed")
|
|
103
|
+
|
|
104
|
+
const statusRes = await client.callTool({
|
|
105
|
+
name: "ecp.get_run_status",
|
|
106
|
+
arguments: { runId: result.run.id },
|
|
107
|
+
})
|
|
108
|
+
const status = parseText(statusRes.content as TextContent[]) as {
|
|
109
|
+
run: { id: string }
|
|
110
|
+
}
|
|
111
|
+
expect(status.run.id).toBe(result.run.id)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("returns a serialized error for an unknown tool", async () => {
|
|
115
|
+
const res = await client.callTool({ name: "ecp.not_a_tool", arguments: {} })
|
|
116
|
+
expect(res.isError).toBe(true)
|
|
117
|
+
const text = (res.content as TextContent[])
|
|
118
|
+
.map((c) => c.text)
|
|
119
|
+
.join("\n")
|
|
120
|
+
expect(text).toContain("not found")
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("lists and reads the capabilities resource", async () => {
|
|
124
|
+
const { resources } = await client.listResources()
|
|
125
|
+
expect(resources.some((r) => r.uri === "ecp://capabilities")).toBe(true)
|
|
126
|
+
|
|
127
|
+
const read = await client.readResource({ uri: "ecp://capabilities" })
|
|
128
|
+
const capabilities = JSON.parse(read.contents[0]!.text as string) as { id: string }[]
|
|
129
|
+
expect(capabilities.some((c) => c.id.includes("echo"))).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("reads a single capability via the templated resource", async () => {
|
|
133
|
+
const read = await client.readResource({ uri: "ecp://capabilities/@executioncontrolprotocol/test.echo" })
|
|
134
|
+
const capability = JSON.parse(read.contents[0]!.text as string) as { id: string }
|
|
135
|
+
expect(capability.id).toBe("@executioncontrolprotocol/test.echo")
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("lists prompts and renders author_workflow", async () => {
|
|
139
|
+
const { prompts } = await client.listPrompts()
|
|
140
|
+
expect(prompts.map((p) => p.name)).toEqual(
|
|
141
|
+
expect.arrayContaining([
|
|
142
|
+
"ecp.author_workflow",
|
|
143
|
+
"ecp.repair_workflow",
|
|
144
|
+
"ecp.explain_environment",
|
|
145
|
+
])
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const prompt = await client.getPrompt({
|
|
149
|
+
name: "ecp.author_workflow",
|
|
150
|
+
arguments: { goal: "echo a value" },
|
|
151
|
+
})
|
|
152
|
+
const text = prompt.messages
|
|
153
|
+
.map((m) => (m.content.type === "text" ? m.content.text : ""))
|
|
154
|
+
.join("\n")
|
|
155
|
+
expect(text).toContain("echo a value")
|
|
156
|
+
expect(text).toContain("@executioncontrolprotocol/test.echo")
|
|
157
|
+
})
|
|
158
|
+
})
|