@sammorrowdrums/mcpi-ext 0.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.
Files changed (213) hide show
  1. package/.dockerignore +6 -0
  2. package/.github/dependabot.yml +25 -0
  3. package/.github/workflows/ci.yml +50 -0
  4. package/.hadolint.yaml +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +7 -0
  7. package/AGENTS.md +118 -0
  8. package/DECISIONS.md +99 -0
  9. package/Dockerfile +39 -0
  10. package/README.md +278 -0
  11. package/aube-lock.yaml +4146 -0
  12. package/dist/code-mode/code-mode-integration.test.d.ts +2 -0
  13. package/dist/code-mode/code-mode-integration.test.d.ts.map +1 -0
  14. package/dist/code-mode/code-mode-integration.test.js +116 -0
  15. package/dist/code-mode/code-mode-integration.test.js.map +1 -0
  16. package/dist/code-mode/eligibility.d.ts +10 -0
  17. package/dist/code-mode/eligibility.d.ts.map +1 -0
  18. package/dist/code-mode/eligibility.js +14 -0
  19. package/dist/code-mode/eligibility.js.map +1 -0
  20. package/dist/code-mode/eligibility.test.d.ts +2 -0
  21. package/dist/code-mode/eligibility.test.d.ts.map +1 -0
  22. package/dist/code-mode/eligibility.test.js +53 -0
  23. package/dist/code-mode/eligibility.test.js.map +1 -0
  24. package/dist/code-mode/executor.d.ts +35 -0
  25. package/dist/code-mode/executor.d.ts.map +1 -0
  26. package/dist/code-mode/executor.js +120 -0
  27. package/dist/code-mode/executor.js.map +1 -0
  28. package/dist/code-mode/executor.test.d.ts +2 -0
  29. package/dist/code-mode/executor.test.d.ts.map +1 -0
  30. package/dist/code-mode/executor.test.js +130 -0
  31. package/dist/code-mode/executor.test.js.map +1 -0
  32. package/dist/code-mode/index.d.ts +65 -0
  33. package/dist/code-mode/index.d.ts.map +1 -0
  34. package/dist/code-mode/index.js +175 -0
  35. package/dist/code-mode/index.js.map +1 -0
  36. package/dist/code-mode/tools.d.ts +45 -0
  37. package/dist/code-mode/tools.d.ts.map +1 -0
  38. package/dist/code-mode/tools.js +70 -0
  39. package/dist/code-mode/tools.js.map +1 -0
  40. package/dist/code-mode/type-hints.d.ts +20 -0
  41. package/dist/code-mode/type-hints.d.ts.map +1 -0
  42. package/dist/code-mode/type-hints.js +189 -0
  43. package/dist/code-mode/type-hints.js.map +1 -0
  44. package/dist/code-mode/type-hints.test.d.ts +2 -0
  45. package/dist/code-mode/type-hints.test.d.ts.map +1 -0
  46. package/dist/code-mode/type-hints.test.js +157 -0
  47. package/dist/code-mode/type-hints.test.js.map +1 -0
  48. package/dist/docker-e2e.d.ts +55 -0
  49. package/dist/docker-e2e.d.ts.map +1 -0
  50. package/dist/docker-e2e.js +235 -0
  51. package/dist/docker-e2e.js.map +1 -0
  52. package/dist/docker-e2e.test.d.ts +2 -0
  53. package/dist/docker-e2e.test.d.ts.map +1 -0
  54. package/dist/docker-e2e.test.js +176 -0
  55. package/dist/docker-e2e.test.js.map +1 -0
  56. package/dist/index.d.ts +3 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +128 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/index.test.d.ts +2 -0
  61. package/dist/index.test.d.ts.map +1 -0
  62. package/dist/index.test.js +8 -0
  63. package/dist/index.test.js.map +1 -0
  64. package/dist/mcp/client-manager.d.ts +46 -0
  65. package/dist/mcp/client-manager.d.ts.map +1 -0
  66. package/dist/mcp/client-manager.js +148 -0
  67. package/dist/mcp/client-manager.js.map +1 -0
  68. package/dist/mcp/client-manager.test.d.ts +2 -0
  69. package/dist/mcp/client-manager.test.d.ts.map +1 -0
  70. package/dist/mcp/client-manager.test.js +204 -0
  71. package/dist/mcp/client-manager.test.js.map +1 -0
  72. package/dist/mcp/config-loader.d.ts +8 -0
  73. package/dist/mcp/config-loader.d.ts.map +1 -0
  74. package/dist/mcp/config-loader.js +43 -0
  75. package/dist/mcp/config-loader.js.map +1 -0
  76. package/dist/mcp/config-loader.test.d.ts +2 -0
  77. package/dist/mcp/config-loader.test.d.ts.map +1 -0
  78. package/dist/mcp/config-loader.test.js +93 -0
  79. package/dist/mcp/config-loader.test.js.map +1 -0
  80. package/dist/mcp/config.d.ts +46 -0
  81. package/dist/mcp/config.d.ts.map +1 -0
  82. package/dist/mcp/config.js +28 -0
  83. package/dist/mcp/config.js.map +1 -0
  84. package/dist/mcp/index.d.ts +5 -0
  85. package/dist/mcp/index.d.ts.map +1 -0
  86. package/dist/mcp/index.js +4 -0
  87. package/dist/mcp/index.js.map +1 -0
  88. package/dist/skills/discover.d.ts +10 -0
  89. package/dist/skills/discover.d.ts.map +1 -0
  90. package/dist/skills/discover.js +60 -0
  91. package/dist/skills/discover.js.map +1 -0
  92. package/dist/skills/format.d.ts +10 -0
  93. package/dist/skills/format.d.ts.map +1 -0
  94. package/dist/skills/format.js +38 -0
  95. package/dist/skills/format.js.map +1 -0
  96. package/dist/skills/format.test.d.ts +2 -0
  97. package/dist/skills/format.test.d.ts.map +1 -0
  98. package/dist/skills/format.test.js +63 -0
  99. package/dist/skills/format.test.js.map +1 -0
  100. package/dist/skills/index.d.ts +6 -0
  101. package/dist/skills/index.d.ts.map +1 -0
  102. package/dist/skills/index.js +6 -0
  103. package/dist/skills/index.js.map +1 -0
  104. package/dist/skills/load-skill-tool.d.ts +40 -0
  105. package/dist/skills/load-skill-tool.d.ts.map +1 -0
  106. package/dist/skills/load-skill-tool.js +104 -0
  107. package/dist/skills/load-skill-tool.js.map +1 -0
  108. package/dist/skills/mcp-tool-proxy.d.ts +11 -0
  109. package/dist/skills/mcp-tool-proxy.d.ts.map +1 -0
  110. package/dist/skills/mcp-tool-proxy.js +116 -0
  111. package/dist/skills/mcp-tool-proxy.js.map +1 -0
  112. package/dist/skills/skill-integration.test.d.ts +2 -0
  113. package/dist/skills/skill-integration.test.d.ts.map +1 -0
  114. package/dist/skills/skill-integration.test.js +119 -0
  115. package/dist/skills/skill-integration.test.js.map +1 -0
  116. package/dist/skills/skill-registry.d.ts +39 -0
  117. package/dist/skills/skill-registry.d.ts.map +1 -0
  118. package/dist/skills/skill-registry.js +48 -0
  119. package/dist/skills/skill-registry.js.map +1 -0
  120. package/dist/skills/skill-registry.test.d.ts +2 -0
  121. package/dist/skills/skill-registry.test.d.ts.map +1 -0
  122. package/dist/skills/skill-registry.test.js +67 -0
  123. package/dist/skills/skill-registry.test.js.map +1 -0
  124. package/dist/test-servers/weather-server.d.ts +12 -0
  125. package/dist/test-servers/weather-server.d.ts.map +1 -0
  126. package/dist/test-servers/weather-server.js +139 -0
  127. package/dist/test-servers/weather-server.js.map +1 -0
  128. package/dist/test-servers/weather-stdio.d.ts +2 -0
  129. package/dist/test-servers/weather-stdio.d.ts.map +1 -0
  130. package/dist/test-servers/weather-stdio.js +11 -0
  131. package/dist/test-servers/weather-stdio.js.map +1 -0
  132. package/dist/tool-cli/cli.d.ts +3 -0
  133. package/dist/tool-cli/cli.d.ts.map +1 -0
  134. package/dist/tool-cli/cli.js +179 -0
  135. package/dist/tool-cli/cli.js.map +1 -0
  136. package/dist/tool-cli/constants.d.ts +7 -0
  137. package/dist/tool-cli/constants.d.ts.map +1 -0
  138. package/dist/tool-cli/constants.js +15 -0
  139. package/dist/tool-cli/constants.js.map +1 -0
  140. package/dist/tool-cli/format.d.ts +8 -0
  141. package/dist/tool-cli/format.d.ts.map +1 -0
  142. package/dist/tool-cli/format.js +57 -0
  143. package/dist/tool-cli/format.js.map +1 -0
  144. package/dist/tool-cli/format.test.d.ts +2 -0
  145. package/dist/tool-cli/format.test.d.ts.map +1 -0
  146. package/dist/tool-cli/format.test.js +30 -0
  147. package/dist/tool-cli/format.test.js.map +1 -0
  148. package/dist/tool-cli/index.d.ts +4 -0
  149. package/dist/tool-cli/index.d.ts.map +1 -0
  150. package/dist/tool-cli/index.js +4 -0
  151. package/dist/tool-cli/index.js.map +1 -0
  152. package/dist/tool-cli/rpc-client.d.ts +6 -0
  153. package/dist/tool-cli/rpc-client.d.ts.map +1 -0
  154. package/dist/tool-cli/rpc-client.js +25 -0
  155. package/dist/tool-cli/rpc-client.js.map +1 -0
  156. package/dist/tool-cli/rpc-server.d.ts +48 -0
  157. package/dist/tool-cli/rpc-server.d.ts.map +1 -0
  158. package/dist/tool-cli/rpc-server.js +220 -0
  159. package/dist/tool-cli/rpc-server.js.map +1 -0
  160. package/dist/tool-cli/rpc-server.test.d.ts +2 -0
  161. package/dist/tool-cli/rpc-server.test.d.ts.map +1 -0
  162. package/dist/tool-cli/rpc-server.test.js +154 -0
  163. package/dist/tool-cli/rpc-server.test.js.map +1 -0
  164. package/eslint.config.js +27 -0
  165. package/images/banner.webp +0 -0
  166. package/images/code-c-maude.webp +0 -0
  167. package/images/nuclear-mcp-football.webp +0 -0
  168. package/images/the-skill-dealer.webp +0 -0
  169. package/mise.toml +65 -0
  170. package/package.json +46 -0
  171. package/scripts/docker-entrypoint.sh +29 -0
  172. package/scripts/test-echo-server.mjs +28 -0
  173. package/scripts/test-mcp-integration.mjs +59 -0
  174. package/scripts/test-tool-cli-smoke.mjs +62 -0
  175. package/src/code-mode/code-mode-integration.test.ts +135 -0
  176. package/src/code-mode/eligibility.test.ts +60 -0
  177. package/src/code-mode/eligibility.ts +16 -0
  178. package/src/code-mode/executor.test.ts +168 -0
  179. package/src/code-mode/executor.ts +163 -0
  180. package/src/code-mode/index.ts +208 -0
  181. package/src/code-mode/tools.ts +110 -0
  182. package/src/code-mode/type-hints.test.ts +187 -0
  183. package/src/code-mode/type-hints.ts +249 -0
  184. package/src/docker-e2e.test.ts +195 -0
  185. package/src/docker-e2e.ts +277 -0
  186. package/src/index.test.ts +8 -0
  187. package/src/index.ts +158 -0
  188. package/src/mcp/client-manager.test.ts +250 -0
  189. package/src/mcp/client-manager.ts +199 -0
  190. package/src/mcp/config-loader.test.ts +115 -0
  191. package/src/mcp/config-loader.ts +46 -0
  192. package/src/mcp/config.ts +40 -0
  193. package/src/mcp/index.ts +4 -0
  194. package/src/skills/discover.ts +81 -0
  195. package/src/skills/format.test.ts +70 -0
  196. package/src/skills/format.ts +42 -0
  197. package/src/skills/index.ts +9 -0
  198. package/src/skills/load-skill-tool.ts +136 -0
  199. package/src/skills/mcp-tool-proxy.ts +143 -0
  200. package/src/skills/skill-integration.test.ts +139 -0
  201. package/src/skills/skill-registry.test.ts +83 -0
  202. package/src/skills/skill-registry.ts +69 -0
  203. package/src/test-servers/weather-server.ts +171 -0
  204. package/src/test-servers/weather-stdio.ts +11 -0
  205. package/src/tool-cli/cli.ts +232 -0
  206. package/src/tool-cli/constants.ts +15 -0
  207. package/src/tool-cli/format.test.ts +33 -0
  208. package/src/tool-cli/format.ts +56 -0
  209. package/src/tool-cli/index.ts +3 -0
  210. package/src/tool-cli/rpc-client.ts +39 -0
  211. package/src/tool-cli/rpc-server.test.ts +193 -0
  212. package/src/tool-cli/rpc-server.ts +287 -0
  213. package/tsconfig.json +21 -0
@@ -0,0 +1,249 @@
1
+ import type { McpTool } from "../mcp/index.js";
2
+
3
+ type JsonSchema = Record<string, unknown>;
4
+
5
+ /**
6
+ * Convert a JSON Schema to a TypeScript type string.
7
+ *
8
+ * Handles objects, arrays, primitives, enums, anyOf/oneOf/allOf, $ref,
9
+ * and circular references (depth guard at 20).
10
+ */
11
+ export function jsonSchemaToTypeString(
12
+ schema: JsonSchema,
13
+ definitions?: Record<string, JsonSchema>,
14
+ depth = 0,
15
+ seen = new Set<JsonSchema>(),
16
+ ): string {
17
+ if (depth > 20) return "unknown";
18
+ if (seen.has(schema)) return "unknown";
19
+ seen.add(schema);
20
+
21
+ // Handle $ref
22
+ if (typeof schema.$ref === "string") {
23
+ const refPath = schema.$ref;
24
+ const refName = refPath.replace(/^#\/(definitions|components\/schemas|\\$defs)\//, "");
25
+ const resolved = definitions?.[refName];
26
+ if (resolved) {
27
+ return jsonSchemaToTypeString(resolved, definitions, depth + 1, seen);
28
+ }
29
+ return "unknown";
30
+ }
31
+
32
+ // Handle enum
33
+ if (Array.isArray(schema.enum)) {
34
+ return schema.enum.map((v: unknown) => JSON.stringify(v)).join(" | ");
35
+ }
36
+
37
+ // Handle const
38
+ if ("const" in schema) {
39
+ return JSON.stringify(schema.const);
40
+ }
41
+
42
+ // Handle anyOf / oneOf
43
+ const unionKey = schema.anyOf ? "anyOf" : schema.oneOf ? "oneOf" : null;
44
+ if (unionKey && Array.isArray(schema[unionKey])) {
45
+ const variants = (schema[unionKey] as JsonSchema[]).map((s) =>
46
+ jsonSchemaToTypeString(s, definitions, depth + 1, seen),
47
+ );
48
+ return variants.join(" | ");
49
+ }
50
+
51
+ // Handle allOf
52
+ if (Array.isArray(schema.allOf)) {
53
+ const parts = (schema.allOf as JsonSchema[]).map((s) =>
54
+ jsonSchemaToTypeString(s, definitions, depth + 1, seen),
55
+ );
56
+ return parts.join(" & ");
57
+ }
58
+
59
+ const type = schema.type as string | string[] | undefined;
60
+
61
+ // Handle type arrays (e.g. ["string", "null"])
62
+ if (Array.isArray(type)) {
63
+ const types = type.map((t: string) => primitiveToTs(t));
64
+ return types.join(" | ");
65
+ }
66
+
67
+ switch (type) {
68
+ case "object":
69
+ return objectToTs(schema, definitions, depth, seen);
70
+ case "array":
71
+ return arrayToTs(schema, definitions, depth, seen);
72
+ case "string":
73
+ case "number":
74
+ case "integer":
75
+ case "boolean":
76
+ case "null":
77
+ return primitiveToTs(type);
78
+ default:
79
+ // No type specified — try to infer from properties
80
+ if (schema.properties) {
81
+ return objectToTs(schema, definitions, depth, seen);
82
+ }
83
+ return "unknown";
84
+ }
85
+ }
86
+
87
+ function primitiveToTs(type: string): string {
88
+ switch (type) {
89
+ case "string":
90
+ return "string";
91
+ case "number":
92
+ case "integer":
93
+ return "number";
94
+ case "boolean":
95
+ return "boolean";
96
+ case "null":
97
+ return "null";
98
+ default:
99
+ return "unknown";
100
+ }
101
+ }
102
+
103
+ function objectToTs(
104
+ schema: JsonSchema,
105
+ definitions: Record<string, JsonSchema> | undefined,
106
+ depth: number,
107
+ seen: Set<JsonSchema>,
108
+ ): string {
109
+ const properties = schema.properties as Record<string, JsonSchema> | undefined;
110
+ if (!properties || Object.keys(properties).length === 0) {
111
+ return "Record<string, unknown>";
112
+ }
113
+
114
+ const required = new Set<string>(
115
+ Array.isArray(schema.required) ? (schema.required as string[]) : [],
116
+ );
117
+ const lines: string[] = [];
118
+
119
+ for (const [key, propSchema] of Object.entries(properties)) {
120
+ const desc = propSchema.description as string | undefined;
121
+ if (desc) {
122
+ lines.push(` /** ${desc} */`);
123
+ }
124
+ const optional = required.has(key) ? "" : "?";
125
+ const typeStr = jsonSchemaToTypeString(propSchema, definitions, depth + 1, seen);
126
+ lines.push(` ${safeName(key)}${optional}: ${typeStr};`);
127
+ }
128
+
129
+ return `{\n${lines.join("\n")}\n}`;
130
+ }
131
+
132
+ function arrayToTs(
133
+ schema: JsonSchema,
134
+ definitions: Record<string, JsonSchema> | undefined,
135
+ depth: number,
136
+ seen: Set<JsonSchema>,
137
+ ): string {
138
+ const items = schema.items as JsonSchema | undefined;
139
+ if (!items) return "unknown[]";
140
+
141
+ // Tuple form
142
+ if (Array.isArray(items)) {
143
+ const tupleTypes = items.map((s: JsonSchema) =>
144
+ jsonSchemaToTypeString(s, definitions, depth + 1, seen),
145
+ );
146
+ return `[${tupleTypes.join(", ")}]`;
147
+ }
148
+
149
+ const itemType = jsonSchemaToTypeString(items, definitions, depth + 1, seen);
150
+ return `${itemType}[]`;
151
+ }
152
+
153
+ /** Ensure property name is a valid JS identifier, quote otherwise. */
154
+ function safeName(name: string): string {
155
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
156
+ }
157
+
158
+ /** Sanitize a tool name to be a valid JS identifier. */
159
+ export function sanitizeToolName(name: string): string {
160
+ return name.replace(/[^a-zA-Z0-9_$]/g, "_");
161
+ }
162
+
163
+ /**
164
+ * Generate TypeScript type declarations for a set of MCP tools.
165
+ *
166
+ * Produces a `declare const codemode: { ... }` block with type-safe
167
+ * method signatures the model can use when writing code.
168
+ */
169
+ export function generateTypeHints(tools: McpTool[]): string {
170
+ if (tools.length === 0) return "";
171
+
172
+ const methods: string[] = [];
173
+
174
+ for (const tool of tools) {
175
+ const safeName = sanitizeToolName(tool.name);
176
+ const inputSchema = tool.inputSchema as JsonSchema;
177
+ const outputSchema = tool.outputSchema as JsonSchema | undefined;
178
+ const definitions = (inputSchema.$defs ??
179
+ inputSchema.definitions ??
180
+ outputSchema?.$defs ??
181
+ outputSchema?.definitions) as Record<string, JsonSchema> | undefined;
182
+
183
+ // Build input type
184
+ const inputType = generateInputType(safeName, inputSchema, definitions);
185
+
186
+ // Build output type
187
+ const outputType = outputSchema ? jsonSchemaToTypeString(outputSchema, definitions) : "unknown";
188
+
189
+ // Build JSDoc
190
+ const jsdoc = buildJsDoc(tool, inputSchema);
191
+
192
+ methods.push(`${jsdoc} ${safeName}: (input: ${inputType}) => Promise<${outputType}>;`);
193
+ }
194
+
195
+ const toolListType = tools.map((t) => `"${escapeStr(t.name)}"`).join(" | ");
196
+
197
+ return [
198
+ "// Code mode type hints — auto-generated from MCP tool schemas",
199
+ "// Available tools are accessed via the `codemode` namespace",
200
+ "",
201
+ `declare const codemode: {`,
202
+ ` /** List all available code mode tool names. */`,
203
+ ` listTools: () => Promise<(${toolListType})[]>;`,
204
+ ` /** Get full type information for specific tools. */`,
205
+ ` describeTools: (names: string[]) => Promise<string>;`,
206
+ ...methods.map((m) => m),
207
+ `};`,
208
+ ].join("\n");
209
+ }
210
+
211
+ function generateInputType(
212
+ _toolSafeName: string,
213
+ inputSchema: JsonSchema,
214
+ definitions: Record<string, JsonSchema> | undefined,
215
+ ): string {
216
+ const properties = inputSchema.properties as Record<string, JsonSchema> | undefined;
217
+ if (!properties || Object.keys(properties).length === 0) {
218
+ return "Record<string, never>";
219
+ }
220
+
221
+ // Always inline the type — avoids emitting unreferenced named type aliases
222
+ const typeStr = jsonSchemaToTypeString(inputSchema, definitions);
223
+ return typeStr;
224
+ }
225
+
226
+ function buildJsDoc(tool: McpTool, inputSchema: JsonSchema): string {
227
+ const lines: string[] = [" /**"];
228
+
229
+ if (tool.description) {
230
+ lines.push(` * ${tool.description}`);
231
+ }
232
+
233
+ const properties = inputSchema.properties as Record<string, JsonSchema> | undefined;
234
+ if (properties) {
235
+ for (const [key, propSchema] of Object.entries(properties)) {
236
+ const desc = propSchema.description as string | undefined;
237
+ if (desc) {
238
+ lines.push(` * @param input.${key} - ${desc}`);
239
+ }
240
+ }
241
+ }
242
+
243
+ lines.push(" */");
244
+ return lines.join("\n") + "\n";
245
+ }
246
+
247
+ function escapeStr(s: string): string {
248
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, " ");
249
+ }
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ parseJsonlLine,
4
+ extractAssistantText,
5
+ extractUsage,
6
+ extractModel,
7
+ buildDockerArgs,
8
+ } from "./docker-e2e.js";
9
+
10
+ describe("parseJsonlLine", () => {
11
+ it("parses valid JSON event", () => {
12
+ const line = '{"type":"message_start","message":{"role":"assistant"}}';
13
+ const result = parseJsonlLine(line);
14
+ expect(result).not.toBeNull();
15
+ expect(result?.type).toBe("message_start");
16
+ expect(result?.raw).toBe(line);
17
+ });
18
+
19
+ it("returns raw type for non-JSON", () => {
20
+ const result = parseJsonlLine("some plain text output");
21
+ expect(result).not.toBeNull();
22
+ expect(result?.type).toBe("raw");
23
+ expect(result?.raw).toBe("some plain text output");
24
+ });
25
+
26
+ it("returns null for empty lines", () => {
27
+ expect(parseJsonlLine("")).toBeNull();
28
+ expect(parseJsonlLine(" ")).toBeNull();
29
+ });
30
+
31
+ it("defaults to unknown type when type field missing", () => {
32
+ const result = parseJsonlLine('{"data":"hello"}');
33
+ expect(result?.type).toBe("unknown");
34
+ });
35
+ });
36
+
37
+ describe("extractAssistantText", () => {
38
+ it("extracts text from message_end events", () => {
39
+ const log = [
40
+ {
41
+ type: "message_end",
42
+ timestamp: 0,
43
+ raw: JSON.stringify({
44
+ type: "message_end",
45
+ message: {
46
+ role: "assistant",
47
+ content: [{ type: "text", text: "Hello world" }],
48
+ },
49
+ }),
50
+ },
51
+ ];
52
+ expect(extractAssistantText(log)).toBe("Hello world");
53
+ });
54
+
55
+ it("concatenates multiple assistant messages", () => {
56
+ const makeEnd = (text: string) => ({
57
+ type: "message_end",
58
+ timestamp: 0,
59
+ raw: JSON.stringify({
60
+ type: "message_end",
61
+ message: {
62
+ role: "assistant",
63
+ content: [{ type: "text", text }],
64
+ },
65
+ }),
66
+ });
67
+ const log = [makeEnd("First"), makeEnd("Second")];
68
+ expect(extractAssistantText(log)).toBe("First\nSecond");
69
+ });
70
+
71
+ it("ignores non-assistant messages", () => {
72
+ const log = [
73
+ {
74
+ type: "message_end",
75
+ timestamp: 0,
76
+ raw: JSON.stringify({
77
+ type: "message_end",
78
+ message: {
79
+ role: "user",
80
+ content: [{ type: "text", text: "ignored" }],
81
+ },
82
+ }),
83
+ },
84
+ ];
85
+ expect(extractAssistantText(log)).toBe("");
86
+ });
87
+
88
+ it("ignores non-message_end events", () => {
89
+ const log = [{ type: "message_start", timestamp: 0, raw: "{}" }];
90
+ expect(extractAssistantText(log)).toBe("");
91
+ });
92
+ });
93
+
94
+ describe("extractUsage", () => {
95
+ it("accumulates usage from assistant messages", () => {
96
+ const log = [
97
+ {
98
+ type: "message_end",
99
+ timestamp: 0,
100
+ raw: JSON.stringify({
101
+ type: "message_end",
102
+ message: {
103
+ role: "assistant",
104
+ content: [],
105
+ usage: {
106
+ input: 100,
107
+ output: 50,
108
+ cacheRead: 10,
109
+ cacheWrite: 5,
110
+ cost: { total: 0.01 },
111
+ },
112
+ },
113
+ }),
114
+ },
115
+ {
116
+ type: "message_end",
117
+ timestamp: 0,
118
+ raw: JSON.stringify({
119
+ type: "message_end",
120
+ message: {
121
+ role: "assistant",
122
+ content: [],
123
+ usage: {
124
+ input: 200,
125
+ output: 100,
126
+ cacheRead: 20,
127
+ cacheWrite: 10,
128
+ cost: { total: 0.02 },
129
+ },
130
+ },
131
+ }),
132
+ },
133
+ ];
134
+ const usage = extractUsage(log);
135
+ expect(usage.turns).toBe(2);
136
+ expect(usage.input).toBe(300);
137
+ expect(usage.output).toBe(150);
138
+ expect(usage.cacheRead).toBe(30);
139
+ expect(usage.cacheWrite).toBe(15);
140
+ expect(usage.cost).toBeCloseTo(0.03);
141
+ });
142
+
143
+ it("returns zeroes for empty log", () => {
144
+ const usage = extractUsage([]);
145
+ expect(usage.turns).toBe(0);
146
+ expect(usage.input).toBe(0);
147
+ });
148
+ });
149
+
150
+ describe("extractModel", () => {
151
+ it("extracts model from message_end", () => {
152
+ const log = [
153
+ {
154
+ type: "message_end",
155
+ timestamp: 0,
156
+ raw: JSON.stringify({
157
+ type: "message_end",
158
+ message: { model: "claude-sonnet-4-5", role: "assistant", content: [] },
159
+ }),
160
+ },
161
+ ];
162
+ expect(extractModel(log)).toBe("claude-sonnet-4-5");
163
+ });
164
+
165
+ it("returns undefined for empty log", () => {
166
+ expect(extractModel([])).toBeUndefined();
167
+ });
168
+ });
169
+
170
+ describe("buildDockerArgs", () => {
171
+ it("builds basic args", () => {
172
+ const args = buildDockerArgs("my-image", {
173
+ task: "Fix the bug",
174
+ token: "ghp_abc123",
175
+ });
176
+ expect(args).toEqual([
177
+ "run",
178
+ "--rm",
179
+ "-e",
180
+ "GITHUB_TOKEN=ghp_abc123",
181
+ "my-image",
182
+ "Fix the bug",
183
+ ]);
184
+ });
185
+
186
+ it("includes model env var when specified", () => {
187
+ const args = buildDockerArgs("my-image", {
188
+ task: "Fix it",
189
+ token: "ghp_abc",
190
+ model: "claude-opus-4-5",
191
+ });
192
+ expect(args).toContain("-e");
193
+ expect(args).toContain("PI_MODEL=claude-opus-4-5");
194
+ });
195
+ });
@@ -0,0 +1,277 @@
1
+ import { spawn } from "node:child_process";
2
+ import type {
3
+ AgentToolResult,
4
+ AgentToolUpdateCallback,
5
+ ExtensionContext,
6
+ } from "@sammorrowdrums/mcpi";
7
+ import { Type, type Static } from "typebox";
8
+
9
+ const DOCKER_IMAGE = "mcpi-ext-e2e";
10
+
11
+ const DockerE2EParams = Type.Object({
12
+ task: Type.String({ description: "The task/prompt to run inside the sandbox" }),
13
+ token: Type.String({
14
+ description: "GitHub token for Copilot model access",
15
+ }),
16
+ model: Type.Optional(
17
+ Type.String({
18
+ description: "Model to use (e.g. claude-sonnet-4-5). Defaults to the provider default.",
19
+ }),
20
+ ),
21
+ timeout: Type.Optional(
22
+ Type.Number({
23
+ description: "Timeout in seconds. Defaults to 300 (5 minutes).",
24
+ }),
25
+ ),
26
+ });
27
+
28
+ type DockerE2EInput = Static<typeof DockerE2EParams>;
29
+
30
+ interface RunLogEntry {
31
+ type: string;
32
+ timestamp: number;
33
+ raw: string;
34
+ }
35
+
36
+ export interface DockerE2EDetails {
37
+ exitCode: number | null;
38
+ log: RunLogEntry[];
39
+ duration: number;
40
+ model?: string;
41
+ usage?: {
42
+ input: number;
43
+ output: number;
44
+ cacheRead: number;
45
+ cacheWrite: number;
46
+ cost: number;
47
+ turns: number;
48
+ };
49
+ }
50
+
51
+ export function parseJsonlLine(line: string): RunLogEntry | null {
52
+ const trimmed = line.trim();
53
+ if (!trimmed) return null;
54
+ try {
55
+ const parsed = JSON.parse(trimmed);
56
+ return { type: parsed.type ?? "unknown", timestamp: Date.now(), raw: trimmed };
57
+ } catch {
58
+ return { type: "raw", timestamp: Date.now(), raw: trimmed };
59
+ }
60
+ }
61
+
62
+ export function extractAssistantText(log: RunLogEntry[]): string {
63
+ const parts: string[] = [];
64
+ for (const entry of log) {
65
+ if (entry.type !== "message_end") continue;
66
+ try {
67
+ const event = JSON.parse(entry.raw);
68
+ const msg = event.message;
69
+ if (msg?.role !== "assistant") continue;
70
+ for (const part of msg.content ?? []) {
71
+ if (part.type === "text") parts.push(part.text);
72
+ }
73
+ } catch {
74
+ // skip malformed
75
+ }
76
+ }
77
+ return parts.join("\n");
78
+ }
79
+
80
+ export function extractUsage(log: RunLogEntry[]) {
81
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
82
+ for (const entry of log) {
83
+ if (entry.type !== "message_end") continue;
84
+ try {
85
+ const event = JSON.parse(entry.raw);
86
+ const msg = event.message;
87
+ if (msg?.role !== "assistant") continue;
88
+ usage.turns++;
89
+ const u = msg.usage;
90
+ if (u) {
91
+ usage.input += u.input ?? 0;
92
+ usage.output += u.output ?? 0;
93
+ usage.cacheRead += u.cacheRead ?? 0;
94
+ usage.cacheWrite += u.cacheWrite ?? 0;
95
+ usage.cost += u.cost?.total ?? 0;
96
+ }
97
+ } catch {
98
+ // skip
99
+ }
100
+ }
101
+ return usage;
102
+ }
103
+
104
+ export function extractModel(log: RunLogEntry[]): string | undefined {
105
+ for (const entry of log) {
106
+ if (entry.type !== "message_end") continue;
107
+ try {
108
+ const event = JSON.parse(entry.raw);
109
+ if (event.message?.model) return event.message.model as string;
110
+ } catch {
111
+ // skip
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ export function buildDockerArgs(image: string, params: DockerE2EInput): string[] {
118
+ const args = ["run", "--rm", "-e", `GITHUB_TOKEN=${params.token}`];
119
+
120
+ if (params.model) {
121
+ args.push("-e", `PI_MODEL=${params.model}`);
122
+ }
123
+
124
+ args.push(image, params.task);
125
+ return args;
126
+ }
127
+
128
+ export const dockerE2ETool = {
129
+ name: "docker_e2e",
130
+ label: "Docker E2E",
131
+ description:
132
+ "Run a task end-to-end inside an isolated Docker container with pi and this project's extensions. Returns the full run log for evaluation.",
133
+ promptSnippet: "Run an isolated e2e task in Docker. Requires a GitHub token and task prompt.",
134
+ parameters: DockerE2EParams,
135
+
136
+ async execute(
137
+ _toolCallId: string,
138
+ params: DockerE2EInput,
139
+ signal: AbortSignal | undefined,
140
+ onUpdate: AgentToolUpdateCallback<DockerE2EDetails> | undefined,
141
+ _ctx: ExtensionContext,
142
+ ): Promise<AgentToolResult<DockerE2EDetails>> {
143
+ const startTime = Date.now();
144
+ const timeoutMs = (params.timeout ?? 300) * 1000;
145
+ const log: RunLogEntry[] = [];
146
+ let lastAssistantText = "";
147
+
148
+ const dockerArgs = buildDockerArgs(DOCKER_IMAGE, params);
149
+
150
+ const exitCode = await new Promise<number | null>((resolve) => {
151
+ const proc = spawn("docker", dockerArgs, {
152
+ stdio: ["ignore", "pipe", "pipe"],
153
+ });
154
+
155
+ let buffer = "";
156
+ let timedOut = false;
157
+ let timeoutHandle: NodeJS.Timeout | undefined;
158
+
159
+ if (timeoutMs > 0) {
160
+ timeoutHandle = setTimeout(() => {
161
+ timedOut = true;
162
+ proc.kill("SIGTERM");
163
+ setTimeout(() => {
164
+ if (!proc.killed) proc.kill("SIGKILL");
165
+ }, 5000);
166
+ }, timeoutMs);
167
+ }
168
+
169
+ const processLine = (line: string) => {
170
+ const entry = parseJsonlLine(line);
171
+ if (!entry) return;
172
+ log.push(entry);
173
+
174
+ if (entry.type === "message_end") {
175
+ const text = extractAssistantText(log);
176
+ if (text) lastAssistantText = text;
177
+ }
178
+
179
+ onUpdate?.({
180
+ content: [
181
+ {
182
+ type: "text",
183
+ text: lastAssistantText || `(running… ${log.length} events)`,
184
+ },
185
+ ],
186
+ details: {
187
+ exitCode: null,
188
+ log,
189
+ duration: Date.now() - startTime,
190
+ model: extractModel(log),
191
+ usage: extractUsage(log),
192
+ },
193
+ });
194
+ };
195
+
196
+ proc.stdout.on("data", (data: Buffer) => {
197
+ buffer += data.toString();
198
+ const lines = buffer.split("\n");
199
+ buffer = lines.pop() ?? "";
200
+ for (const line of lines) processLine(line);
201
+ });
202
+
203
+ proc.stderr.on("data", (data: Buffer) => {
204
+ const text = data.toString().trim();
205
+ if (text) {
206
+ log.push({ type: "stderr", timestamp: Date.now(), raw: text });
207
+ }
208
+ });
209
+
210
+ proc.on("close", (code) => {
211
+ if (timeoutHandle) clearTimeout(timeoutHandle);
212
+ if (buffer.trim()) processLine(buffer);
213
+ if (timedOut) {
214
+ log.push({
215
+ type: "timeout",
216
+ timestamp: Date.now(),
217
+ raw: `Timed out after ${params.timeout ?? 300}s`,
218
+ });
219
+ }
220
+ resolve(timedOut ? null : (code ?? 1));
221
+ });
222
+
223
+ proc.on("error", (err) => {
224
+ if (timeoutHandle) clearTimeout(timeoutHandle);
225
+ log.push({
226
+ type: "error",
227
+ timestamp: Date.now(),
228
+ raw: err.message,
229
+ });
230
+ resolve(null);
231
+ });
232
+
233
+ if (signal) {
234
+ const onAbort = () => {
235
+ proc.kill("SIGTERM");
236
+ setTimeout(() => {
237
+ if (!proc.killed) proc.kill("SIGKILL");
238
+ }, 5000);
239
+ };
240
+ if (signal.aborted) onAbort();
241
+ else signal.addEventListener("abort", onAbort, { once: true });
242
+ }
243
+ });
244
+
245
+ const duration = Date.now() - startTime;
246
+ const assistantText = extractAssistantText(log);
247
+ const usage = extractUsage(log);
248
+ const model = extractModel(log);
249
+ const fullLog = log.map((e) => e.raw).join("\n");
250
+
251
+ const summary = [
252
+ `## Docker E2E Run Complete`,
253
+ ``,
254
+ `**Exit code:** ${exitCode ?? "killed"}`,
255
+ `**Duration:** ${(duration / 1000).toFixed(1)}s`,
256
+ model ? `**Model:** ${model}` : null,
257
+ usage.turns > 0
258
+ ? `**Usage:** ${usage.turns} turns, ↑${usage.input} ↓${usage.output} tokens, $${usage.cost.toFixed(4)}`
259
+ : null,
260
+ ``,
261
+ `### Output`,
262
+ assistantText || "(no assistant output)",
263
+ ``,
264
+ `### Full Log (${log.length} events)`,
265
+ "```",
266
+ fullLog,
267
+ "```",
268
+ ]
269
+ .filter((l) => l !== null)
270
+ .join("\n");
271
+
272
+ return {
273
+ content: [{ type: "text", text: summary }],
274
+ details: { exitCode, log, duration, model, usage },
275
+ };
276
+ },
277
+ };