@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 +14 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/CorpusServer.ts +16 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/ServerBridge.ts +113 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/StdioConnection.ts +29 -20
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/capabilities.json +1 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/configuration.ts +26 -5
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/index.ts +14 -5
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/process.ts +12 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/serverProtocol.ts +92 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/servers.ts +76 -0
- package/package.json +1 -1
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(
|
|
36
|
-
if (this._pending) throw new ConnectionFailure(
|
|
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(
|
|
41
|
-
const timer = setTimeout(() => this.stop(
|
|
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(
|
|
48
|
+
this._child.stdin.write(line, error => { if (error) this.stop(`${this._label} input pipe failed.`); });
|
|
49
49
|
} catch {
|
|
50
|
-
this.stop(
|
|
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(
|
|
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(
|
|
60
|
+
this.stop(`${this._label} notification failed.`);
|
|
61
61
|
throw this._failure;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
dispose(): void { this.stop(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
149
|
-
private readonly streamFailed = (): void => this.stop(
|
|
150
|
-
private readonly spawnFailed = (): void => this.stop(
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|