@evref-bl/pharo-agent 0.10.2 → 0.12.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/src/init.js CHANGED
@@ -120,7 +120,7 @@ outside generated/copied files and outside the host guide's managed block.
120
120
  Refreshing project files does not install a newer package or upgrade image tools.
121
121
 
122
122
  \`plexus.json\` declares this project's images. A freshly initialized
123
- project declares none; register one before the first \`pharo-agent open\`:
123
+ project declares none; register one before the first \`pharo-agent image start\`:
124
124
 
125
125
  \`\`\`sh
126
126
  pharo-agent image templates
@@ -131,16 +131,22 @@ pharo-agent image add dev --template '<launcher template name>'
131
131
  \`image templates\` names the templates the launcher offers, with the category
132
132
  of each; a name two categories offer also needs \`--category <name>\`. Any
133
133
  version the launcher offers can be registered, and one outside the MCP range
134
- opens without MCP tools or a source projection, saying so.
134
+ starts without MCP tools or a source projection, saying so.
135
135
 
136
136
  \`image add\` makes the first registered image the default. \`pharo-agent image
137
137
  use <id>\` changes the default, and \`pharo-agent image remove <id>\` drops a
138
- declaration. Close an image before removing its declaration.
138
+ declaration. Stop an image before removing its declaration.
139
139
 
140
- Use \`pharo-agent open\` before live-image work and edit projected source under
141
- \`.pharo-agent/mounts\`. Call a tool with
142
- \`pharo-agent pharo <tool> [arguments-json]\`, and \`pharo-agent mcp\` exposes the
143
- image as a command-scoped stdio MCP server.
140
+ Use \`pharo-agent image start\` before live-image work. On macOS with fuse-t
141
+ installed the image's source is also projected under \`.pharo-agent/mounts\`
142
+ and can be edited as files; everywhere else the Pharo tools are the way in,
143
+ and \`image start\` says so.
144
+
145
+ With the pharo-agent plugin enabled for this project, the image's tools are MCP
146
+ tools: start an image with \`image_start\` and call the rest by name. Without it,
147
+ call a tool with \`pharo-agent pharo <tool> [arguments-json]\`, and
148
+ \`pharo-agent mcp\` serves the same surface as a command-scoped stdio MCP
149
+ server.
144
150
 
145
151
  \`pharo-agent pharo list\` names the tools the image advertises, which are the
146
152
  common ones rather than all of it. Find the rest through the catalogue rather
@@ -152,11 +158,11 @@ pharo-agent pharo tool_get '{"name":"repository_commit"}'
152
158
  pharo-agent pharo repository_commit '{"name":"MCP","message":"..."}'
153
159
  \`\`\`
154
160
 
155
- Several images can be open at once. Every image-facing command takes
156
- \`--image <id>\`; without it the default image answers. \`pharo-agent open
157
- --image <id>\` opens another image without closing the first,
158
- \`pharo-agent status\` lists what is open, and \`pharo-agent close\` closes
159
- everything this project opened.
161
+ Several images can run at once. \`pharo-agent image start <id>\` and
162
+ \`pharo-agent image stop <id>\` name their image, \`pharo\` and \`mcp\` take
163
+ \`--image <id>\`, and without either the default image answers.
164
+ \`pharo-agent image list\` says which are running, and
165
+ \`pharo-agent image stop --all\` stops every one this project started.
160
166
 
161
167
  Image, Tonel, Iceberg, disk Git, and host processes are separate states.
162
168
 
package/src/live.js CHANGED
@@ -9,22 +9,63 @@ import {
9
9
  stopProjectImage,
10
10
  } from "@evref-bl/plexus";
11
11
  import { callImageTool } from "./gateway.js";
12
- import { readProjectConfig, writeProjectConfig } from "./project.js";
12
+ import { readProjectConfig } from "./project.js";
13
13
  import {
14
- defaultImageId,
15
- openedImageIsAlive,
16
14
  readConfig,
17
- readOpenImages,
15
+ readRunningImages,
16
+ recordedImageIsRunning,
18
17
  resolveImageId,
19
18
  resolveMountPath,
20
19
  selectRunningImage,
21
20
  smalltalkString,
22
21
  unappliedPharoMcpPin,
23
- writeOpenImages,
22
+ writeRunningImages,
24
23
  } from "./runtime.js";
25
24
 
26
25
  export const pifsRelease = "v0.9.2";
27
26
 
27
+ // fuse-t ships as a macOS installer package, so it lands in /usr/local
28
+ // whatever Homebrew's prefix is. The unversioned symlink is what the daemon
29
+ // links against.
30
+ export const fuseLibraryPath = "/usr/local/lib/libfuse-t.dylib";
31
+
32
+ /**
33
+ * Whether a source projection can be mounted here.
34
+ *
35
+ * PIFS needs macOS and fuse-t, which is a kernel-adjacent install the user
36
+ * has to approve. PLexus and the Pharo MCP run anywhere, so until this was
37
+ * checked, `image start` mounted unconditionally and then stopped the image
38
+ * when the mount failed -- every image on Linux and Windows, and on any Mac
39
+ * without fuse-t. The image and its tools were never the part that could not
40
+ * work.
41
+ *
42
+ * @param {{ platform?: string, exists?: (path: string) => boolean }} [host]
43
+ */
44
+ export function projectionAvailability(host = {}) {
45
+ const platform = host.platform ?? process.platform;
46
+ const exists = host.exists ?? fs.existsSync;
47
+ if (platform !== "darwin") {
48
+ return {
49
+ available: false,
50
+ reason: "A source projection needs macOS and fuse-t, which this platform"
51
+ + ` (${platform}) cannot provide.`,
52
+ };
53
+ }
54
+ if (!exists(fuseLibraryPath)) {
55
+ return {
56
+ available: false,
57
+ reason: "A source projection needs fuse-t, which is not installed."
58
+ + " Install it with `brew install --cask fuse-t`.",
59
+ };
60
+ }
61
+ return { available: true };
62
+ }
63
+
64
+ export function projectionUnavailableWarning(imageId, reason) {
65
+ return `${reason} Image ${imageId} is running with its Pharo tools and no`
66
+ + " source projection; read and edit its code through the tools.";
67
+ }
68
+
28
69
  // The class-side PIFSServer messages this plugin sends into the image. The
29
70
  // release automation checks that a published PIFS still implements them: a pin
30
71
  // bump that leaves a removed selector in place passes every test here, because
@@ -85,62 +126,28 @@ export function resultPrintString(result) {
85
126
  return namedPrintString(result) ?? textPartText(result);
86
127
  }
87
128
 
129
+ /**
130
+ * The status is spelled out rather than taken as a string: this read branched
131
+ * for a long time on "declared" and "failed", which PLexus does not report,
132
+ * so a declared image was never created and the launch that followed failed
133
+ * on an image that was not there. Naming the four it does report makes the
134
+ * typecheck reject a fifth.
135
+ *
136
+ * @param {{ images: { imageId: string,
137
+ * status: "absent" | "stopped" | "starting" | "running" }[] }} listed
138
+ * @param {string} imageId
139
+ */
88
140
  export function imageCreationPlan(listed, imageId) {
89
141
  const summary = listed.images.find((image) => image.imageId === imageId);
90
142
  if (!summary) {
91
143
  throw new Error(`PLexus does not know image ${imageId}.`);
92
144
  }
93
- // PLexus reports an image it has no runtime state for as declared.
94
- if (summary.status === "declared") return "create";
95
- // A creation that failed leaves runtime state that blocks both a retry and a
96
- // fresh declaration of the same id, so the id would be spent for good. Clear
97
- // it and create again.
98
- if (summary.status === "failed") return "reset";
145
+ // "absent" is PLexus's word for no image file on disk, which is what a
146
+ // declared image looks like until something creates it.
147
+ if (summary.status === "absent") return "create";
99
148
  return "none";
100
149
  }
101
150
 
102
- // The launcher resolves a template name against its default group only, so an
103
- // image naming a template from any other group cannot be created until the
104
- // project config says which group that is. Find it once, on first creation.
105
- export async function resolveTemplateCategory(projectRoot, launcher, imageId) {
106
- const config = readProjectConfig(projectRoot);
107
- const images = Array.isArray(config.images) ? config.images : [];
108
- const configured = images.find((image) => image.id === imageId);
109
- const create = configured?.create;
110
- if (!create?.templateName || create.templateCategory) {
111
- return create?.templateCategory;
112
- }
113
-
114
- const listed = await launcher.listTemplates();
115
- const templates = Array.isArray(listed?.templates) ? listed.templates : [];
116
- const categories = [
117
- ...new Set(
118
- templates
119
- .filter((template) => template?.name === create.templateName)
120
- .map((template) => template?.templateCategory)
121
- .filter(Boolean),
122
- ),
123
- ];
124
- if (categories.length === 0) {
125
- throw new Error(`No template named ${create.templateName} in any category.`);
126
- }
127
- if (categories.length > 1) {
128
- throw new Error(
129
- `Template ${create.templateName} exists in several categories: ${categories.join(", ")}.`
130
- + ` Set create.templateCategory for image ${imageId} to choose one.`,
131
- );
132
- }
133
-
134
- const [templateCategory] = categories;
135
- writeProjectConfig(projectRoot, {
136
- ...config,
137
- images: images.map((image) => (image.id === imageId
138
- ? { ...image, create: { ...image.create, templateCategory } }
139
- : image)),
140
- });
141
- return templateCategory;
142
- }
143
-
144
151
  export function describeLifecycleFailure(failure) {
145
152
  const stderr = failure.launcherResult?.raw?.stderr?.trim();
146
153
  return [
@@ -185,71 +192,89 @@ export function fetchPifsRelease(cloneDirectory, release = pifsRelease) {
185
192
  return git("fetch", "--tags", "--quiet", "origin");
186
193
  }
187
194
 
188
- export async function openAgentImage(projectRoot, imageId) {
195
+ export async function startAgentImage(projectRoot, imageId) {
189
196
  const resolved = resolveImageId(projectRoot, imageId);
190
- let openImages = readOpenImages(projectRoot);
197
+ let runningImages = readRunningImages(projectRoot);
191
198
  let restarted = false;
192
- if (openImages[resolved]) {
193
- if (openedImageIsAlive(openImages[resolved])) {
194
- return { alreadyOpen: true, ...openImages[resolved] };
199
+ if (runningImages[resolved]) {
200
+ if (recordedImageIsRunning(runningImages[resolved])) {
201
+ return { alreadyRunning: true, ...runningImages[resolved] };
195
202
  }
196
- // The image this record names has exited. Forget it and open again,
197
- // rather than hand back an endpoint nothing answers and say it is open.
198
- const { [resolved]: exited, ...remaining } = openImages;
199
- openImages = remaining;
200
- writeOpenImages(projectRoot, openImages);
203
+ // The image this record names has exited. Forget it and start again,
204
+ // rather than hand back an endpoint nothing answers and call it running.
205
+ const { [resolved]: exited, ...remaining } = runningImages;
206
+ runningImages = remaining;
207
+ writeRunningImages(projectRoot, runningImages);
201
208
  restarted = true;
202
209
  }
203
- fetchPifsRelease(pifsCloneDirectory(projectRoot));
210
+ if (projectionAvailability().available) {
211
+ fetchPifsRelease(pifsCloneDirectory(projectRoot));
212
+ }
204
213
  const launcher = new ScopedPharoLauncher({ projectRoot });
205
- const plan = imageCreationPlan(launcher.listImages(), resolved);
214
+ const plan = imageCreationPlan(await launcher.listImages(), resolved);
206
215
  const created = plan !== "none";
207
216
  if (created) {
208
- if (plan === "reset") await launcher.resetImage(resolved);
209
- await resolveTemplateCategory(projectRoot, launcher, resolved);
217
+ // PLexus resolves which category the template lives in.
210
218
  await launcher.createImage(resolved);
211
219
  }
212
- const opened = await runLifecycle(
220
+ const started = await runLifecycle(
213
221
  () => startProjectImage({ projectRoot, imageId: resolved }),
214
- `PLexus could not open image ${resolved}.`,
222
+ `PLexus could not start image ${resolved}.`,
215
223
  );
216
224
  let image;
217
225
  let mcpServerId;
218
226
  try {
219
- ({ image, mcpServerId } = selectRunningImage(opened.state, resolved));
227
+ ({ image, mcpServerId } = selectRunningImage(started.state, resolved));
220
228
  } catch (error) {
221
229
  // An image whose Pharo version is outside the MCP contract still runs, and
222
230
  // is still worth having: the caller asked for that version, and the tools
223
231
  // it cannot offer are the only thing missing. Keep it, and say what is gone.
224
- const withoutMcp = unsupportedRunningImage(opened.state, resolved);
232
+ const withoutMcp = unsupportedRunningImage(started.state, resolved);
225
233
  if (!withoutMcp) {
226
234
  // The image is running but unusable to us, so do not leave it behind.
227
235
  await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
228
236
  throw error;
229
237
  }
230
- const open = { statePath: opened.statePath, imageId: resolved, mcp: false };
231
- writeOpenImages(projectRoot, { ...openImages, [resolved]: open });
232
- return { ...open, created, warning: pharoMcpWarning(withoutMcp) };
238
+ const record = { statePath: started.statePath, imageId: resolved, mcp: false };
239
+ writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
240
+ return { ...record, created, warning: pharoMcpWarning(withoutMcp) };
241
+ }
242
+ const unappliedPin = unappliedPharoMcpPin(image);
243
+ const projection = projectionAvailability();
244
+ if (!projection.available) {
245
+ // The image and its tools are the point; the projection is the extra this
246
+ // package bundles where the platform allows it.
247
+ const record = { statePath: started.statePath, imageId: image.id, mcpServerId };
248
+ writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
249
+ return {
250
+ imageId: image.id,
251
+ created,
252
+ ...(restarted ? { restarted } : {}),
253
+ warning: [
254
+ projectionUnavailableWarning(image.id, projection.reason),
255
+ unappliedPin,
256
+ ].filter(Boolean).join("\n"),
257
+ };
233
258
  }
259
+
234
260
  const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
235
261
  fs.mkdirSync(path.dirname(mountPath), { recursive: true });
236
- const open = {
237
- statePath: opened.statePath,
262
+ const record = {
263
+ statePath: started.statePath,
238
264
  imageId: image.id,
239
265
  mcpServerId,
240
266
  mountPath,
241
267
  };
242
268
  let projectionPort;
243
269
  try {
244
- projectionPort = await startProjection(projectRoot, open, image.imageName);
270
+ projectionPort = await startProjection(projectRoot, record, image.imageName);
245
271
  } catch (error) {
246
272
  // The projection is half-started at best and nothing records it yet, so
247
- // close the image rather than leave an image and a mount nothing owns.
273
+ // stop the image rather than leave an image and a mount nothing owns.
248
274
  await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
249
275
  throw error;
250
276
  }
251
- writeOpenImages(projectRoot, { ...openImages, [resolved]: { ...open, projectionPort } });
252
- const unappliedPin = unappliedPharoMcpPin(image);
277
+ writeRunningImages(projectRoot, { ...runningImages, [resolved]: { ...record, projectionPort } });
253
278
  return {
254
279
  imageId: image.id,
255
280
  created,
@@ -270,13 +295,13 @@ function unsupportedRunningImage(state, imageId) {
270
295
  export function pharoMcpWarning(image) {
271
296
  const reason = image.pharoMcpContract?.reason
272
297
  ?? "This image Pharo version is outside the supported Pharo MCP range.";
273
- return `${reason} Image ${image.id} is open without MCP tools or a source projection;`
298
+ return `${reason} Image ${image.id} is running without MCP tools or a source projection;`
274
299
  + " drive it with the Pharo command line instead.";
275
300
  }
276
301
 
277
- async function startProjection(projectRoot, open, imageName) {
278
- const result = await callImageTool(projectRoot, open, "image_evaluate", {
279
- code: pifsStartCode(open.mountPath, imageName),
302
+ async function startProjection(projectRoot, record, imageName) {
303
+ const result = await callImageTool(projectRoot, record, "image_evaluate", {
304
+ code: pifsStartCode(record.mountPath, imageName),
280
305
  });
281
306
  const projectionPort = Number(resultPrintString(result));
282
307
  if (!Number.isInteger(projectionPort) || projectionPort < 1) {
@@ -287,76 +312,73 @@ async function startProjection(projectRoot, open, imageName) {
287
312
  return projectionPort;
288
313
  }
289
314
 
290
- async function closeOneImage(projectRoot, openImages, imageId) {
291
- const open = openImages[imageId];
315
+ async function stopOneImage(projectRoot, runningImages, imageId) {
316
+ const record = runningImages[imageId];
292
317
  let projectionError;
293
- // An image opened without MCP has no projection to stop, and no route to
318
+ // An image started without MCP has no projection to stop, and no route to
294
319
  // ask over either.
295
- if (open.projectionPort !== undefined) {
320
+ if (record.projectionPort !== undefined) {
296
321
  try {
297
- await callImageTool(projectRoot, open, "image_evaluate", {
298
- code: pifsStopCode(open.projectionPort),
322
+ await callImageTool(projectRoot, record, "image_evaluate", {
323
+ code: pifsStopCode(record.projectionPort),
299
324
  });
300
325
  } catch (error) {
301
326
  projectionError = error;
302
327
  }
303
328
  }
304
- const closed = await runLifecycle(
329
+ const stopped = await runLifecycle(
305
330
  () => stopProjectImage({ projectRoot, imageId }),
306
- `PLexus could not close image ${imageId}.`,
331
+ `PLexus could not stop image ${imageId}.`,
307
332
  );
308
- return { stoppedImages: closed.stoppedImages.map((image) => image.id), projectionError };
333
+ return { stoppedImage: stopped.stoppedImage?.id, projectionError };
309
334
  }
310
335
 
311
- export async function closeAgentImage(projectRoot, imageId) {
312
- const openImages = readOpenImages(projectRoot);
313
- const identifiers = imageId === undefined
314
- ? Object.keys(openImages)
336
+ /**
337
+ * Stop one image, or with `all`, every image this project started. Naming no
338
+ * image stops the default one, as every other command addresses it; `all` is
339
+ * the cleanup, and says so where the caller can see it.
340
+ *
341
+ * @param {string} projectRoot
342
+ * @param {string} [imageId]
343
+ * @param {{ all?: boolean }} [options]
344
+ */
345
+ export async function stopAgentImage(projectRoot, imageId, options = {}) {
346
+ const runningImages = readRunningImages(projectRoot);
347
+ if (options.all && imageId !== undefined) {
348
+ throw new Error("Stopping every image takes no image id.");
349
+ }
350
+ const identifiers = options.all
351
+ ? Object.keys(runningImages)
315
352
  : [resolveImageId(projectRoot, imageId)];
316
353
  if (identifiers.length === 0) {
317
- throw new Error("No open pharo-agent image.");
354
+ throw new Error("No pharo-agent image is running.");
318
355
  }
319
356
  for (const identifier of identifiers) {
320
- if (!openImages[identifier]) {
321
- throw new Error(`Image ${identifier} is not open.`);
357
+ if (!runningImages[identifier]) {
358
+ throw new Error(`Image ${identifier} is not running.`);
322
359
  }
323
360
  }
324
361
 
325
362
  const stoppedImages = [];
326
363
  const projectionErrors = [];
327
- const remaining = { ...openImages };
364
+ const remaining = { ...runningImages };
328
365
  for (const identifier of identifiers) {
329
- const closed = await closeOneImage(projectRoot, openImages, identifier);
366
+ const stopped = await stopOneImage(projectRoot, runningImages, identifier);
330
367
  delete remaining[identifier];
331
- writeOpenImages(projectRoot, remaining);
332
- stoppedImages.push(...closed.stoppedImages);
333
- if (closed.projectionError) {
334
- projectionErrors.push(`${identifier}: ${closed.projectionError.message}`);
368
+ writeRunningImages(projectRoot, remaining);
369
+ if (stopped.stoppedImage) {
370
+ stoppedImages.push(stopped.stoppedImage);
371
+ }
372
+ if (stopped.projectionError) {
373
+ projectionErrors.push(`${identifier}: ${stopped.projectionError.message}`);
335
374
  }
336
375
  }
337
376
  if (projectionErrors.length > 0) {
338
- throw new Error(`Images closed, but PIFS cleanup failed: ${projectionErrors.join("; ")}`);
377
+ throw new Error(`Images stopped, but PIFS cleanup failed: ${projectionErrors.join("; ")}`);
339
378
  }
340
379
  return { stoppedImages };
341
380
  }
342
381
 
343
- export function agentImageStatus(projectRoot) {
344
- const openImages = readOpenImages(projectRoot);
345
- let defaultImage;
346
- try {
347
- defaultImage = defaultImageId(projectRoot);
348
- } catch {
349
- defaultImage = undefined;
350
- }
351
- return {
352
- ...(defaultImage ? { defaultImage } : {}),
353
- images: Object.values(openImages).map((open) => ({
354
- ...open,
355
- isDefault: open.imageId === defaultImage,
356
- })),
357
- };
358
- }
359
-
360
382
  export async function listImageTemplates(projectRoot) {
361
383
  // `image add --template` takes a name the launcher must already know, and a
362
384
  // name that lands in two categories needs the category as well, so answer
@@ -0,0 +1,154 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+ import {
8
+ callImageTool,
9
+ listImageTools,
10
+ toolResult,
11
+ version,
12
+ withImageSelector,
13
+ } from "./gateway.js";
14
+ import { listImages } from "./images.js";
15
+ import { startAgentImage, stopAgentImage } from "./live.js";
16
+ import { readRunningImage } from "./runtime.js";
17
+
18
+ // The host loads only tool names and these instructions at session start and
19
+ // defers the rest, so this is what decides whether Pharo tools are searched
20
+ // for at all. It is the conditional guidance the generated guide gives,
21
+ // written where the host can act on it.
22
+ export const serverInstructions = `Live Pharo images for this project: reading and writing Smalltalk code in the
23
+ image, running tests, finding senders and implementors, applying critiques,
24
+ and projecting image source as files on disk.
25
+
26
+ Search these tools whenever the task involves Pharo, Smalltalk, a Tonel
27
+ package, or a .st file. Start an image with image_start before calling the
28
+ image's own tools, which are listed once an image answers; image_list says
29
+ what is already running.
30
+
31
+ The image advertises the common tools rather than all of it. Use tool_search
32
+ and tool_get to find the rest, then call one by name like any other.`;
33
+
34
+ // Listed whether or not an image is running, so the list is never empty. A
35
+ // host that caches tools and ignores tools/list_changed would otherwise hold
36
+ // an empty list for the whole session and never learn that one had started.
37
+ const lifecycleTools = [
38
+ {
39
+ name: "image_start",
40
+ description:
41
+ "Start one of this project's Pharo images, creating it from its template "
42
+ + "when it does not exist yet. The image's own tools are listed once it "
43
+ + "answers. On macOS with fuse-t its source also mounts as files; "
44
+ + "elsewhere the image starts without that and says so.",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ image: {
49
+ type: "string",
50
+ description: "Image id to start. Defaults to the project's default image.",
51
+ },
52
+ },
53
+ },
54
+ },
55
+ {
56
+ name: "image_list",
57
+ description:
58
+ "List this project's declared images, which are running, and the route "
59
+ + "and mount of each running one.",
60
+ inputSchema: { type: "object", properties: {} },
61
+ },
62
+ {
63
+ name: "image_stop",
64
+ description:
65
+ "Stop a running image, and any source projection this project started for it.",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ image: {
70
+ type: "string",
71
+ description: "Image id to stop. Defaults to the project's default image.",
72
+ },
73
+ all: {
74
+ type: "boolean",
75
+ description: "Stop every image this project started, instead of one.",
76
+ },
77
+ },
78
+ },
79
+ },
80
+ ];
81
+
82
+ // Resolved per request rather than once at startup. A host starts this server
83
+ // when its session starts, which is before anyone has started an image, so
84
+ // resolving then meant the server died on its first line.
85
+ async function runningImageTools(projectRoot, defaultImage) {
86
+ let running;
87
+ try {
88
+ running = readRunningImage(projectRoot, defaultImage);
89
+ } catch {
90
+ // No image running, none configured, or one running without MCP. The image
91
+ // contributes no tools yet and the lifecycle tools say how to change that.
92
+ return [];
93
+ }
94
+ return (await listImageTools(projectRoot, running))
95
+ .map((tool) => withImageSelector(tool, running.imageId));
96
+ }
97
+
98
+ export async function serveImagesMcp(resolveProjectRoot, defaultImage) {
99
+ const server = new Server(
100
+ { name: "pharo-agent", version },
101
+ { capabilities: { tools: { listChanged: true } }, instructions: serverInstructions },
102
+ );
103
+
104
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
105
+ let projectRoot;
106
+ try {
107
+ projectRoot = resolveProjectRoot();
108
+ } catch {
109
+ // No project here. The lifecycle tools are still listed rather than
110
+ // nothing, so the host has a server that works and a call can say why.
111
+ return { tools: lifecycleTools };
112
+ }
113
+ return {
114
+ tools: [...lifecycleTools, ...(await runningImageTools(projectRoot, defaultImage))],
115
+ };
116
+ });
117
+
118
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
119
+ const { image, all, ...argumentsValue } = request.params.arguments ?? {};
120
+ const selected = image ?? defaultImage;
121
+ // Throws when there is no project, which is the answer the caller needs.
122
+ const projectRoot = resolveProjectRoot();
123
+
124
+ // Matched before the image's catalogue: these three names belong to this
125
+ // server, and an image that happened to hold one must not decide what
126
+ // starting an image means.
127
+ if (request.params.name === "image_start") {
128
+ const started = await startAgentImage(projectRoot, selected);
129
+ await server.sendToolListChanged();
130
+ return toolResult({ ok: true, ...started });
131
+ }
132
+ if (request.params.name === "image_list") {
133
+ return toolResult({ ok: true, ...listImages(projectRoot) });
134
+ }
135
+ if (request.params.name === "image_stop") {
136
+ const stopped = all
137
+ ? await stopAgentImage(projectRoot, undefined, { all: true })
138
+ : await stopAgentImage(projectRoot, selected);
139
+ await server.sendToolListChanged();
140
+ return toolResult({ ok: true, ...stopped });
141
+ }
142
+
143
+ // Listed tools are the common ones, not the whole catalogue, and the image
144
+ // answers for any tool it holds. Refusing a name this list does not carry
145
+ // would hide tools that tool_search just found, and the image's own error
146
+ // is the better answer for a name nothing holds.
147
+ const running = readRunningImage(projectRoot, selected);
148
+ return toolResult(
149
+ await callImageTool(projectRoot, running, request.params.name, argumentsValue),
150
+ );
151
+ });
152
+
153
+ await server.connect(new StdioServerTransport());
154
+ }