@cratis/pi 7.94.0 → 7.95.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/README.md CHANGED
@@ -33,6 +33,20 @@ yields to the managed installation to avoid duplicate resources and hooks. The P
33
33
  package manages only Pi; it neither configures other harnesses nor owns a local
34
34
  managed corpus.
35
35
 
36
+ ## MCP servers from the corpus
37
+
38
+ The packaged `cratis-mcp` extension starts every stdio server declared in
39
+ `mcp-servers.json` whose `profiles` intersect the profiles listed explicitly in
40
+ `.cratis/ai.json` (a profile reached only through another profile's `composes` does
41
+ not start a server; Screenplay keeps its composed selection) (for
42
+ example `codescene` for `cratis/codescene`, `playwright` for `cratis/playwright`).
43
+ Each server gets its own connection, so one failing to start never stops the others,
44
+ and its tools are namespaced by server id (`playwright_browser_navigate`). The
45
+ `command`, `args` and optional literal `env` are used verbatim, without a shell. A
46
+ malformed selected server is skipped with one notice; set
47
+ `mcpServers.<id>.enabled` to `false` in `.cratis/ai.json` to opt a server out.
48
+ `capabilities.json` beside the extension declares this behavior to the Cratis CLI.
49
+
36
50
  ## Screenplay MCP tools
37
51
 
38
52
  The packaged `cratis-mcp` extension starts the Screenplay MCP server
@@ -0,0 +1,16 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /** A validated stdio server from the corpus descriptor; command, args and env are used verbatim. */
5
+ export interface CorpusServer {
6
+ id: string;
7
+ command: string;
8
+ args: string[];
9
+ env: Record<string, string>;
10
+ }
11
+
12
+ /** The servers to start for a project plus the explicit messages for everything skipped. */
13
+ export interface ServerSelection {
14
+ servers: CorpusServer[];
15
+ messages: string[];
16
+ }
@@ -0,0 +1,113 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
5
+ import { object } from './configuration.ts';
6
+ import type { CorpusServer } from './CorpusServer.ts';
7
+ import { startServer } from './process.ts';
8
+ import { discoverServer, mapServerResult, type ServerCatalog } from './serverProtocol.ts';
9
+ import { selectedCorpusServers } from './servers.ts';
10
+ import type { StdioConnection } from './StdioConnection.ts';
11
+
12
+ function withDeadline<T>(work: Promise<T>, milliseconds: number, id: string): Promise<T> {
13
+ let timer: ReturnType<typeof setTimeout> | undefined;
14
+ const expired = new Promise<never>((_resolve, reject) => {
15
+ timer = setTimeout(() => reject(new Error(`MCP server '${id}' did not become ready within ${milliseconds} ms.`)), milliseconds);
16
+ timer.unref();
17
+ });
18
+ work.catch(() => { /* The deadline already reported the outcome; the disposed connection rejects the abandoned request. */ });
19
+ return Promise.race([work, expired]).finally(() => clearTimeout(timer));
20
+ }
21
+
22
+ /** Bridge every selected corpus server except Screenplay: one connection each, isolated failures, tools namespaced by server id. */
23
+ export interface ServerBridge {
24
+ refresh(context: ExtensionContext, newSession: boolean): Promise<void>;
25
+ dispose(): void;
26
+ }
27
+
28
+ export function createServerBridge(pi: ExtensionAPI, corpus: string, connect: (project: string, server: CorpusServer) => StdioConnection = startServer, deadlineMilliseconds = 15_000): ServerBridge {
29
+ const connections = new Map<string, StdioConnection>();
30
+ const registered = new Set<string>();
31
+ const paused = new Set<string>();
32
+ let selectionKey: string | undefined;
33
+
34
+ const notify = (context: ExtensionContext, messages: string[]): void => {
35
+ if (context.hasUI) messages.forEach(message => context.ui.notify(message, 'warning'));
36
+ };
37
+ const deactivate = (): void => {
38
+ const active = pi.getActiveTools();
39
+ active.filter(name => registered.has(name)).forEach(name => paused.add(name));
40
+ if (active.some(name => registered.has(name))) pi.setActiveTools(active.filter(name => !registered.has(name)));
41
+ };
42
+ const dispose = (): void => {
43
+ connections.forEach(connection => connection.dispose());
44
+ connections.clear();
45
+ deactivate();
46
+ };
47
+ interface Started { server: CorpusServer; connection: StdioConnection; catalog?: ServerCatalog; messages: string[] }
48
+ /** Connect and discover only; registration happens afterwards in selection order so name claims are deterministic. */
49
+ const start = async (context: ExtensionContext, server: CorpusServer): Promise<Started> => {
50
+ const connection = connect(context.cwd, server);
51
+ connections.set(server.id, connection);
52
+ try {
53
+ const catalog = await withDeadline(discoverServer(connection, server.id), deadlineMilliseconds, server.id);
54
+ if (connections.get(server.id) !== connection || connection.stopped) throw new Error(`MCP server '${server.id}' session ended during discovery.`);
55
+ return { server, connection, catalog, messages: [...catalog.messages] };
56
+ } catch (error) {
57
+ connection.dispose();
58
+ if (connections.get(server.id) === connection) connections.delete(server.id);
59
+ return { server, connection, messages: [`MCP server '${server.id}' is unavailable: ${error instanceof Error ? error.message : String(error)}`] };
60
+ }
61
+ };
62
+ const register = ({ server, connection, catalog, messages }: Started, claimed: Set<string>): void => {
63
+ const existing = new Set(pi.getAllTools().map(tool => tool.name));
64
+ for (const tool of catalog?.tools ?? []) {
65
+ if (claimed.has(tool.nativeName) || (existing.has(tool.nativeName) && !registered.has(tool.nativeName))) {
66
+ messages.push(`MCP server '${server.id}' tool '${tool.name}' was skipped: '${tool.nativeName}' conflicts with another tool.`);
67
+ continue;
68
+ }
69
+ claimed.add(tool.nativeName);
70
+ pi.registerTool({
71
+ name: tool.nativeName,
72
+ label: `${server.id} ${tool.name}`,
73
+ description: tool.description,
74
+ parameters: tool.parameters,
75
+ executionMode: 'sequential',
76
+ async execute(_toolCallId, parameters, signal) {
77
+ if (connections.get(server.id) !== connection || connection.stopped || !pi.getActiveTools().includes(tool.nativeName)) {
78
+ throw new Error(`MCP server '${server.id}' is unavailable in this session.`);
79
+ }
80
+ if (!object(parameters)) throw new Error('MCP arguments must be an object.');
81
+ const result = mapServerResult(await connection.request('tools/call', { name: tool.name, arguments: parameters }, signal), server.id);
82
+ if (result.details.isError) throw new Error(result.content.map(part => part.type === 'text' ? part.text : '').join('\n') || `MCP server '${server.id}' reported a failed tool call.`);
83
+ return result;
84
+ },
85
+ });
86
+ }
87
+ };
88
+ const refresh = async (context: ExtensionContext, newSession: boolean): Promise<void> => {
89
+ let selection: ReturnType<typeof selectedCorpusServers>;
90
+ try { selection = selectedCorpusServers(context.cwd, corpus); }
91
+ catch (error) { dispose(); selectionKey = undefined; throw error; }
92
+ const key = JSON.stringify(selection);
93
+ if (!newSession && key === selectionKey) return;
94
+ const previouslyActive = new Set([...pi.getActiveTools(), ...paused]);
95
+ const oldNames = new Set(registered);
96
+ dispose();
97
+ selectionKey = key;
98
+ const messages = [...selection.messages];
99
+ const claimed = new Set<string>();
100
+ // Servers start in parallel; a slow or hung one is abandoned at its own deadline and never delays the others.
101
+ const outcomes = await Promise.all(selection.servers.map(server => start(context, server)));
102
+ for (const outcome of outcomes) { register(outcome, claimed); messages.push(...outcome.messages); }
103
+ claimed.forEach(name => registered.add(name));
104
+ // Never re-enable a user-disabled tool. SDK allow/exclude restrictions still filter registration.
105
+ const next = pi.getActiveTools().filter(name => !registered.has(name) || (claimed.has(name) && (!oldNames.has(name) || previouslyActive.has(name))));
106
+ for (const name of claimed) if (oldNames.has(name) && previouslyActive.has(name)) next.push(name);
107
+ pi.setActiveTools([...new Set(next)]);
108
+ paused.clear();
109
+ notify(context, messages);
110
+ };
111
+
112
+ return { refresh, dispose: () => { dispose(); selectionKey = undefined; } };
113
+ }
@@ -15,7 +15,7 @@ export class StdioConnection {
15
15
  private _termination?: ReturnType<typeof setTimeout>;
16
16
  private readonly _maximumBytes = 8 * 1024 * 1024;
17
17
 
18
- constructor(private readonly _child: ChildProcessWithoutNullStreams, private readonly _timeout = 30_000) {
18
+ constructor(private readonly _child: ChildProcessWithoutNullStreams, private readonly _timeout = 30_000, private readonly _label = 'Screenplay', private readonly _tolerant = false) {
19
19
  _child.stdout.on('data', this.receive);
20
20
  _child.stdout.on('end', this.ended);
21
21
  _child.stdout.on('error', this.streamFailed);
@@ -32,22 +32,22 @@ export class StdioConnection {
32
32
 
33
33
  async request(method: string, params: Record<string, unknown>, signal?: AbortSignal, mutation = false): Promise<unknown> {
34
34
  if (this._failure) throw this._failure;
35
- if (signal?.aborted) throw new ConnectionFailure('Screenplay request cancelled before sending.');
36
- if (this._pending) throw new ConnectionFailure('Screenplay is busy; parallel requests are not queued.');
35
+ if (signal?.aborted) throw new ConnectionFailure(`${this._label} request cancelled before sending.`);
36
+ if (this._pending) throw new ConnectionFailure(`${this._label} is busy; parallel requests are not queued.`);
37
37
  const id = ++this._sequence;
38
38
  const line = this.encode({ jsonrpc: '2.0', id, method, params });
39
39
  return new Promise((resolve, reject) => {
40
- const abort = () => this.stop('Screenplay request cancelled after sending.');
41
- const timer = setTimeout(() => this.stop('Screenplay request timed out.'), this._timeout);
40
+ const abort = () => this.stop(`${this._label} request cancelled after sending.`);
41
+ const timer = setTimeout(() => this.stop(`${this._label} request timed out.`), this._timeout);
42
42
  this._pending = {
43
43
  id, mutation, resolve, reject,
44
44
  cleanup: () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); },
45
45
  };
46
46
  signal?.addEventListener('abort', abort, { once: true });
47
47
  try {
48
- this._child.stdin.write(line, error => { if (error) this.stop('Screenplay input pipe failed.'); });
48
+ this._child.stdin.write(line, error => { if (error) this.stop(`${this._label} input pipe failed.`); });
49
49
  } catch {
50
- this.stop('Screenplay input pipe failed.');
50
+ this.stop(`${this._label} input pipe failed.`);
51
51
  }
52
52
  });
53
53
  }
@@ -55,18 +55,18 @@ export class StdioConnection {
55
55
  notify(method: string): void {
56
56
  if (this._failure) throw this._failure;
57
57
  try {
58
- this._child.stdin.write(this.encode({ jsonrpc: '2.0', method }), error => { if (error) this.stop('Screenplay notification failed.'); });
58
+ this._child.stdin.write(this.encode({ jsonrpc: '2.0', method }), error => { if (error) this.stop(`${this._label} notification failed.`); });
59
59
  } catch {
60
- this.stop('Screenplay notification failed.');
60
+ this.stop(`${this._label} notification failed.`);
61
61
  throw this._failure;
62
62
  }
63
63
  }
64
64
 
65
- dispose(): void { this.stop('Screenplay session closed.'); }
65
+ dispose(): void { this.stop(`${this._label} session closed.`); }
66
66
 
67
67
  private encode(value: unknown): string {
68
68
  const line = `${JSON.stringify(value)}\n`;
69
- if (Buffer.byteLength(line) > this._maximumBytes) throw new ConnectionFailure('Screenplay request exceeds the 8 MiB transport limit.');
69
+ if (Buffer.byteLength(line) > this._maximumBytes) throw new ConnectionFailure(`${this._label} request exceeds the 8 MiB transport limit.`);
70
70
  return line;
71
71
  }
72
72
 
@@ -82,7 +82,7 @@ export class StdioConnection {
82
82
  this._buffer = Buffer.alloc(0);
83
83
  this.accept(line);
84
84
  } catch {
85
- this.stop('Screenplay returned invalid UTF-8 protocol data.');
85
+ this.stop(`${this._label} returned invalid UTF-8 protocol data.`);
86
86
  }
87
87
  if (this.stopped) return;
88
88
  start = index + 1;
@@ -92,7 +92,7 @@ export class StdioConnection {
92
92
 
93
93
  private append(chunk: Buffer): boolean {
94
94
  if (this._buffer.length + chunk.length > this._maximumBytes) {
95
- this.stop('Screenplay response exceeds the 8 MiB transport limit.');
95
+ this.stop(`${this._label} response exceeds the 8 MiB transport limit.`);
96
96
  return false;
97
97
  }
98
98
  this._buffer = Buffer.concat([this._buffer, chunk]);
@@ -103,6 +103,13 @@ export class StdioConnection {
103
103
  try {
104
104
  const message: unknown = JSON.parse(line);
105
105
  if (!object(message) || message.jsonrpc !== '2.0') throw new Error();
106
+ if ('method' in message && this._tolerant) {
107
+ // Third-party servers may notify or ask; this client offers no capabilities, so refuse requests and ignore notifications.
108
+ if ('id' in message && typeof message.method === 'string') {
109
+ this._child.stdin.write(this.encode({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }));
110
+ }
111
+ return;
112
+ }
106
113
  if ('method' in message) {
107
114
  // This pinned server does not send requests or changing tool lists.
108
115
  throw new Error();
@@ -113,14 +120,14 @@ export class StdioConnection {
113
120
  this._pending = undefined;
114
121
  pending.cleanup();
115
122
  if (object(message.error)) {
116
- pending.reject(new ConnectionFailure(`Screenplay protocol error ${message.error.code}: ${message.error.message}`, pending.mutation));
123
+ pending.reject(new ConnectionFailure(`${this._label} protocol error ${message.error.code}: ${message.error.message}`, pending.mutation));
117
124
  // A server failure during mutation is not evidence that nothing was written.
118
- if (pending.mutation) this.stop('Screenplay mutation failed with a protocol error.', true);
125
+ if (pending.mutation) this.stop(`${this._label} mutation failed with a protocol error.`, true);
119
126
  } else {
120
127
  pending.resolve(message.result);
121
128
  }
122
129
  } catch {
123
- this.stop('Screenplay returned malformed or unexpected JSON-RPC.');
130
+ this.stop(`${this._label} returned malformed or unexpected JSON-RPC.`);
124
131
  }
125
132
  }
126
133
 
@@ -145,11 +152,13 @@ export class StdioConnection {
145
152
  }
146
153
 
147
154
  private readonly drain = (): void => { /* Drain server stderr without exposing it as a protocol result. */ };
148
- private readonly ended = (): void => this.stop('Screenplay output closed before session shutdown.');
149
- private readonly streamFailed = (): void => this.stop('Screenplay stdio failed.');
150
- private readonly spawnFailed = (): void => this.stop('Cannot launch native cratis screenplay mcp. Install/update the Cratis CLI through its normal channel; this extension never downloads a runtime.');
155
+ private readonly ended = (): void => this.stop(`${this._label} output closed before session shutdown.`);
156
+ private readonly streamFailed = (): void => this.stop(`${this._label} stdio failed.`);
157
+ private readonly spawnFailed = (): void => this.stop(this._label !== 'Screenplay'
158
+ ? `Cannot launch the ${this._label} MCP server command. Check that it is installed; this extension never downloads a runtime.`
159
+ : 'Cannot launch native cratis screenplay mcp. Install/update the Cratis CLI through its normal channel; this extension never downloads a runtime.');
151
160
  private readonly closed = (): void => {
152
- this.stop('Screenplay process exited. Check that the installed Cratis CLI includes the embedded Screenplay runtime; no runtime is downloaded by this extension.');
161
+ this.stop(this._label !== 'Screenplay' ? `${this._label} process exited.` : 'Screenplay process exited. Check that the installed Cratis CLI includes the embedded Screenplay runtime; no runtime is downloaded by this extension.');
153
162
  clearTimeout(this._termination);
154
163
  this._child.stdout.removeListener('error', this.streamFailed);
155
164
  this._child.stdin.removeListener('error', this.streamFailed);
@@ -0,0 +1 @@
1
+ { "servers": "selected-profiles" }
@@ -8,7 +8,7 @@ export function object(value: unknown): value is Record<string, unknown> {
8
8
  return value !== null && typeof value === 'object' && !Array.isArray(value);
9
9
  }
10
10
 
11
- function readJson(path: string): Record<string, unknown> {
11
+ export function readJson(path: string): Record<string, unknown> {
12
12
  if (lstatSync(path).size > 1024 * 1024) throw new Error(`Cratis configuration is too large: ${path}`);
13
13
  const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
14
14
  if (!object(value)) throw new Error(`Expected a JSON object: ${path}`);
@@ -21,6 +21,10 @@ function strings(value: unknown): string[] {
21
21
  return value;
22
22
  }
23
23
 
24
+ export function supportedSchema(version: unknown): boolean {
25
+ return typeof version === 'string' && /^1\.\d+$/.test(version);
26
+ }
27
+
24
28
  /** Resolve through existing directories only, refusing every source-root symlink. Never creates a directory. */
25
29
  export function physicalRoot(project: string, root: string): string {
26
30
  if (!root || isAbsolute(root) || root.includes('\0')) throw new Error('Screenplay root must be a nonempty project-relative directory.');
@@ -45,8 +49,16 @@ export function physicalRoot(project: string, root: string): string {
45
49
  return current;
46
50
  }
47
51
 
48
- /** Select only the shipped Screenplay descriptor, never project-supplied commands or environments. */
49
- export function selectedServer(project: string, corpus: string) {
52
+ export interface ProfileResolution {
53
+ configuration: Record<string, unknown>;
54
+ selected: Set<string>;
55
+ /** Profiles listed directly in `.cratis/ai.json`, before any `composes` expansion. */
56
+ explicit: Set<string>;
57
+ nonApplicationSelection: boolean;
58
+ }
59
+
60
+ /** Resolve the selected profiles (after `composes` expansion) from the project's `.cratis/ai.json`. */
61
+ export function resolveProfiles(project: string, corpus: string): ProfileResolution | undefined {
50
62
  let configuration: Record<string, unknown>;
51
63
  try {
52
64
  configuration = readJson(join(project, '.cratis', 'ai.json'));
@@ -86,7 +98,14 @@ export function selectedServer(project: string, corpus: string) {
86
98
  strings(profile.composes).forEach(child => select(child, false, applicationPath));
87
99
  };
88
100
  requested.forEach(id => select(id, true));
89
- if (!selected.has('cratis/screenplay')) return undefined;
101
+ return { configuration, selected, explicit: new Set(requested), nonApplicationSelection };
102
+ }
103
+
104
+ /** Select only the shipped Screenplay descriptor, never project-supplied commands or environments. */
105
+ export function selectedServer(project: string, corpus: string) {
106
+ const resolution = resolveProfiles(project, corpus);
107
+ if (!resolution?.selected.has('cratis/screenplay')) return undefined;
108
+ const { configuration, nonApplicationSelection } = resolution;
90
109
  const overrides = configuration.mcpServers;
91
110
  if (overrides !== undefined && !object(overrides)) throw new Error('Invalid mcpServers configuration.');
92
111
  const screenplay = object(overrides) ? overrides.screenplay : undefined;
@@ -94,7 +113,9 @@ export function selectedServer(project: string, corpus: string) {
94
113
  if (object(screenplay) && screenplay.enabled === false) return undefined;
95
114
  if (object(screenplay) && screenplay.enabled !== undefined && screenplay.enabled !== true) throw new Error('Screenplay MCP enabled must be a boolean.');
96
115
  const descriptor = readJson(join(corpus, 'mcp-servers.json'));
97
- if (descriptor.schemaVersion !== '1.0' || !Array.isArray(descriptor.servers)) throw new Error('Unsupported Cratis MCP descriptor.');
116
+ // Any 1.x descriptor is accepted; another major loads no corpus MCP servers (the generic bridge reports it once).
117
+ if (!supportedSchema(descriptor.schemaVersion)) return undefined;
118
+ if (!Array.isArray(descriptor.servers)) throw new Error('Unsupported Cratis MCP descriptor.');
98
119
  const servers = descriptor.servers.filter(server => object(server) && server.id === 'screenplay');
99
120
  if (servers.length !== 1 || !object(servers[0])) throw new Error('Expected exactly one Screenplay MCP descriptor.');
100
121
  const server = servers[0];
@@ -7,8 +7,10 @@ import { fileURLToPath } from 'node:url';
7
7
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
8
8
  import { ConnectionFailure } from './ConnectionFailure.ts';
9
9
  import { object, selectedServer } from './configuration.ts';
10
- import { startConnection, supportsNoModelStartup } from './process.ts';
10
+ import type { CorpusServer } from './CorpusServer.ts';
11
+ import { startConnection, startServer, supportsNoModelStartup } from './process.ts';
11
12
  import { discover, failedResult, mapResult } from './protocol.ts';
13
+ import { createServerBridge } from './ServerBridge.ts';
12
14
  import type { DiscoveredTool } from './DiscoveredTool.ts';
13
15
  import type { StdioConnection } from './StdioConnection.ts';
14
16
 
@@ -16,7 +18,8 @@ const extensionDirectory = dirname(fileURLToPath(import.meta.url));
16
18
  const bundledCorpus = resolve(extensionDirectory, '..', '..', '..', '..');
17
19
 
18
20
  /** Native tools go through Pi's tool_call restrictions and normal execution/result pipeline. */
19
- export function registerBridge(pi: ExtensionAPI, corpus: string, connect = startConnection, checkVersion = supportsNoModelStartup): void {
21
+ export function registerBridge(pi: ExtensionAPI, corpus: string, connect = startConnection, checkVersion = supportsNoModelStartup, connectServer: (project: string, server: CorpusServer) => StdioConnection = startServer): void {
22
+ const servers = createServerBridge(pi, corpus, connectServer);
20
23
  let connection: StdioConnection | undefined;
21
24
  let selectionKey: string | undefined;
22
25
  let attempted = false;
@@ -149,9 +152,15 @@ export function registerBridge(pi: ExtensionAPI, corpus: string, connect = start
149
152
  }
150
153
  };
151
154
 
152
- pi.on('session_start', (_event, context) => refresh(context, true));
153
- pi.on('before_agent_start', async (_event, context) => { await refresh(context); });
154
- pi.on('session_shutdown', () => { dispose(); failures.clear(); });
155
+ // Screenplay and the other corpus servers fail independently; the first failure is surfaced after both ran.
156
+ const refreshAll = async (context: ExtensionContext, newSession: boolean): Promise<void> => {
157
+ const outcomes = await Promise.allSettled([refresh(context, newSession), servers.refresh(context, newSession)]);
158
+ const failed = outcomes.find(outcome => outcome.status === 'rejected');
159
+ if (failed) throw failed.reason;
160
+ };
161
+ pi.on('session_start', (_event, context) => refreshAll(context, true));
162
+ pi.on('before_agent_start', async (_event, context) => { await refreshAll(context, false); });
163
+ pi.on('session_shutdown', () => { dispose(); servers.dispose(); failures.clear(); });
155
164
  pi.on('tool_result', event => {
156
165
  if (!registered.has(event.toolName)) return;
157
166
  const failure = failures.get(event.toolCallId);
@@ -2,6 +2,7 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import { execFile, spawn } from 'node:child_process';
5
+ import type { CorpusServer } from './CorpusServer.ts';
5
6
  import { StdioConnection } from './StdioConnection.ts';
6
7
 
7
8
  /** Preserve OS/runtime lookup only; never copy arbitrary credentials or descriptor environment values. */
@@ -43,3 +44,14 @@ export function startConnection(project: string, launch: typeof spawn = spawn):
43
44
  windowsHide: true,
44
45
  }));
45
46
  }
47
+
48
+ /** Launch a corpus server verbatim (no shell, no interpolation): the default environment plus its declared literal env only. */
49
+ export function startServer(project: string, server: CorpusServer, launch: typeof spawn = spawn): StdioConnection {
50
+ return new StdioConnection(launch(server.command, server.args, {
51
+ cwd: project,
52
+ env: { ...childEnvironment(), ...server.env },
53
+ shell: false,
54
+ stdio: ['pipe', 'pipe', 'pipe'],
55
+ windowsHide: true,
56
+ }), 30_000, server.id, true);
57
+ }
@@ -0,0 +1,92 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { TSchema } from 'typebox';
5
+ import { object } from './configuration.ts';
6
+ import type { StdioConnection } from './StdioConnection.ts';
7
+
8
+ /** A tool of a corpus server other than Screenplay, exposed under a name namespaced by the server id. */
9
+ export interface ServerTool {
10
+ name: string;
11
+ nativeName: string;
12
+ description: string;
13
+ parameters: TSchema;
14
+ }
15
+
16
+ export interface ServerCatalog {
17
+ tools: ServerTool[];
18
+ messages: string[];
19
+ }
20
+
21
+ const maximumTools = 128;
22
+ const maximumPages = 8;
23
+ const maximumNativeNameLength = 64;
24
+ const maximumDescriptionLength = 4 * 1024;
25
+ const maximumSchemaLength = 64 * 1024;
26
+ const maximumImageLength = 2 * 1024 * 1024;
27
+ const imageTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
28
+
29
+ /** Initialize and list tools; tools that cannot be exposed safely are skipped with a message rather than failing the server. */
30
+ export async function discoverServer(connection: StdioConnection, id: string): Promise<ServerCatalog> {
31
+ const initialized = await connection.request('initialize', {
32
+ protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'cratis.pi', version: '1.0.0' },
33
+ });
34
+ if (!object(initialized) || !object(initialized.capabilities) || !object(initialized.capabilities.tools)) {
35
+ throw new Error(`MCP server '${id}' did not advertise tool support.`);
36
+ }
37
+ connection.notify('notifications/initialized');
38
+ const listed: unknown[] = [];
39
+ let cursor: unknown;
40
+ for (let page = 0; page < maximumPages; page++) {
41
+ const response = await connection.request('tools/list', cursor === undefined ? {} : { cursor });
42
+ if (!object(response) || !Array.isArray(response.tools)) throw new Error(`MCP server '${id}' returned an invalid tool list.`);
43
+ listed.push(...response.tools);
44
+ if (listed.length > maximumTools) throw new Error(`MCP server '${id}' exposes more than ${maximumTools} tools.`);
45
+ cursor = response.nextCursor;
46
+ if (cursor === undefined) break;
47
+ if (typeof cursor !== 'string') throw new Error(`MCP server '${id}' returned an invalid pagination cursor.`);
48
+ }
49
+ const catalog: ServerCatalog = { tools: [], messages: [] };
50
+ const names = new Set<string>();
51
+ for (const tool of listed) {
52
+ const name = object(tool) ? tool.name : undefined;
53
+ if (!object(tool) || typeof name !== 'string' || !name || !object(tool.inputSchema) || tool.inputSchema.type !== 'object' ||
54
+ (tool.description !== undefined && typeof tool.description !== 'string')) {
55
+ catalog.messages.push(`MCP server '${id}' has a tool with an invalid name or input schema; it was skipped.`);
56
+ continue;
57
+ }
58
+ if (JSON.stringify(tool.inputSchema).length > maximumSchemaLength || ((tool.description as string | undefined)?.length ?? 0) > maximumDescriptionLength) {
59
+ catalog.messages.push(`MCP server '${id}' tool '${name}' was skipped: its description or input schema is too large.`);
60
+ continue;
61
+ }
62
+ const nativeName = `${id}_${name.replace(/[^A-Za-z0-9_]/g, '_')}`;
63
+ if (nativeName.length > maximumNativeNameLength || names.has(nativeName)) {
64
+ catalog.messages.push(`MCP server '${id}' tool '${name}' was skipped: its namespaced name is too long or not unique.`);
65
+ continue;
66
+ }
67
+ names.add(nativeName);
68
+ catalog.tools.push({ name, nativeName, description: (tool.description as string | undefined) ?? '', parameters: tool.inputSchema as TSchema });
69
+ }
70
+ if (!catalog.tools.length) throw new Error(`MCP server '${id}' exposes no usable tools.`);
71
+ return catalog;
72
+ }
73
+
74
+ /** Map an MCP result to Pi content. Server-reported errors throw so Pi marks the call failed. */
75
+ export function mapServerResult(result: unknown, id: string) {
76
+ if (!object(result) || !Array.isArray(result.content) || (result.isError !== undefined && typeof result.isError !== 'boolean')) {
77
+ throw new Error(`Malformed MCP result from '${id}'.`);
78
+ }
79
+ const content = result.content.map((part: unknown) => {
80
+ if (object(part) && part.type === 'text' && typeof part.text === 'string') return { type: 'text' as const, text: part.text };
81
+ if (object(part) && part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string') {
82
+ if (!imageTypes.has(part.mimeType) || part.data.length > maximumImageLength) return { type: 'text' as const, text: `[image omitted: unsupported type or larger than ${maximumImageLength / 1024 / 1024} MiB]` };
83
+ return { type: 'image' as const, data: part.data, mimeType: part.mimeType };
84
+ }
85
+ return { type: 'text' as const, text: `[unsupported ${object(part) ? String(part.type) : 'unknown'} content omitted]` };
86
+ });
87
+ const maximumCharacters = 32 * 1024;
88
+ const bounded = content.map(part => part.type === 'text' && part.text.length > maximumCharacters
89
+ ? { ...part, text: `${part.text.slice(0, maximumCharacters)}\n[display truncated]` }
90
+ : part);
91
+ return { content: bounded, details: { isError: result.isError === true, structuredContent: result.structuredContent } };
92
+ }
@@ -0,0 +1,76 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { join } from 'node:path';
5
+ import { object, readJson, resolveProfiles, supportedSchema } from './configuration.ts';
6
+ import type { CorpusServer, ServerSelection } from './CorpusServer.ts';
7
+
8
+ const validId = /^[A-Za-z0-9_-]+$/;
9
+
10
+ function isSelected(entry: Record<string, unknown>, selected: Set<string>): boolean {
11
+ return Array.isArray(entry.profiles) && entry.profiles.some(profile => typeof profile === 'string' && selected.has(profile));
12
+ }
13
+
14
+ function problem(entry: Record<string, unknown>): string | undefined {
15
+ if (typeof entry.id !== 'string' || !validId.test(entry.id)) return 'the id must match ^[A-Za-z0-9_-]+$';
16
+ if (entry.transport !== 'stdio') return 'only the stdio transport is supported';
17
+ if (typeof entry.command !== 'string' || !entry.command) return 'the command is missing or empty';
18
+ if (!Array.isArray(entry.args) || !entry.args.every(argument => typeof argument === 'string')) return 'args must be an array of strings';
19
+ if (entry.env !== undefined && (!object(entry.env) || !Object.values(entry.env).every(value => typeof value === 'string'))) {
20
+ return 'env must be an object of literal string values';
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ /** `mcpServers.<id>.enabled`: false opts out, a non-boolean is an error for that server only. */
26
+ function optOut(overrides: Record<string, unknown>, id: string): 'enabled' | 'disabled' | string {
27
+ const override = overrides[id];
28
+ if (override === undefined) return 'enabled';
29
+ if (!object(override)) return 'the mcpServers configuration must be an object';
30
+ if (override.enabled === undefined || override.enabled === true) return 'enabled';
31
+ return override.enabled === false ? 'disabled' : 'mcpServers enabled must be a boolean';
32
+ }
33
+
34
+ /** Select every stdio server whose profiles intersect the explicitly listed profiles (never composed ones), except Screenplay which keeps its own path. */
35
+ export function selectedCorpusServers(project: string, corpus: string): ServerSelection {
36
+ const selection: ServerSelection = { servers: [], messages: [] };
37
+ const resolution = resolveProfiles(project, corpus);
38
+ if (!resolution) return selection;
39
+ const descriptor = readJson(join(corpus, 'mcp-servers.json'));
40
+ if (!supportedSchema(descriptor.schemaVersion)) {
41
+ selection.messages.push(`Cratis MCP descriptor schemaVersion '${String(descriptor.schemaVersion)}' is not supported; no corpus MCP servers were loaded.`);
42
+ return selection;
43
+ }
44
+ if (!Array.isArray(descriptor.servers)) {
45
+ selection.messages.push('Cratis MCP descriptor has no servers list; no corpus MCP servers were loaded.');
46
+ return selection;
47
+ }
48
+ const overrides = resolution.configuration.mcpServers;
49
+ if (overrides !== undefined && !object(overrides)) {
50
+ selection.messages.push('Invalid mcpServers configuration; no corpus MCP servers were loaded.');
51
+ return selection;
52
+ }
53
+ const entries = descriptor.servers.filter(object);
54
+ const counts = new Map<unknown, number>();
55
+ entries.forEach(entry => counts.set(entry.id, (counts.get(entry.id) ?? 0) + 1));
56
+ const reported = new Set<unknown>();
57
+ for (const entry of entries) {
58
+ if (entry.id === 'screenplay') continue;
59
+ // An entry without profiles can never be selected, so it is ignored like any other unselected entry.
60
+ if (!isSelected(entry, resolution.explicit)) continue;
61
+ const label = `MCP server '${String(entry.id)}'`;
62
+ if (counts.get(entry.id)! > 1) {
63
+ if (!reported.has(entry.id)) selection.messages.push(`${label} was skipped: the descriptor declares the id more than once.`);
64
+ reported.add(entry.id);
65
+ continue;
66
+ }
67
+ const invalid = problem(entry);
68
+ if (invalid) { selection.messages.push(`${label} was skipped: ${invalid}.`); continue; }
69
+ const id = entry.id as string;
70
+ const state = object(overrides) ? optOut(overrides, id) : 'enabled';
71
+ if (state === 'disabled') continue;
72
+ if (state !== 'enabled') { selection.messages.push(`${label} was skipped: ${state}.`); continue; }
73
+ selection.servers.push({ id, command: entry.command as string, args: entry.args as string[], env: { ...(entry.env as Record<string, string> | undefined) } });
74
+ }
75
+ return selection;
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "7.94.0",
3
+ "version": "7.95.0",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [