@causyne/agent 1.10.12 → 1.10.17
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/LICENSE +1 -1
- package/docs/ARCHITECTURE.md +136 -7
- package/docs/SECURITY_REVIEW.md +13 -1
- package/docs/THIRD_PARTY_NOTICES.md +43 -10
- package/package.json +11 -4
- package/src/agentLoop.js +161 -93
- package/src/agentProfiles.js +135 -0
- package/src/agentProfiles.ts +143 -0
- package/src/approvalGrants.js +17 -35
- package/src/approvalGrants.ts +54 -0
- package/src/auditLog.d.ts +3 -0
- package/src/benchmarks/planCache.js +159 -0
- package/src/browser/camofoxRuntime.js +93 -0
- package/src/browser/camofoxRuntime.ts +112 -0
- package/src/browser/causyneBrowser.js +495 -0
- package/src/browser/causyneBrowser.ts +594 -0
- package/src/browser/searchProviders.js +156 -0
- package/src/browser/searchProviders.ts +156 -0
- package/src/browser/snapshotWindow.js +28 -0
- package/src/browser/snapshotWindow.ts +40 -0
- package/src/browser/urlPolicy.js +89 -0
- package/src/browser/urlPolicy.ts +89 -0
- package/src/checkpoints.js +19 -6
- package/src/commands.js +120 -8
- package/src/config.d.ts +2 -0
- package/src/cortex/autoCondenser.js +1 -1
- package/src/hooks.js +150 -0
- package/src/hooks.ts +193 -0
- package/src/mcp/mcpClient.js +520 -132
- package/src/mcp/mcpClient.ts +584 -0
- package/src/mcp/mcpRegistry.js +399 -185
- package/src/mcp/mcpRegistry.ts +451 -0
- package/src/planCache.js +529 -225
- package/src/planCache.ts +840 -0
- package/src/plugins/pluginManager.js +460 -0
- package/src/plugins/pluginRunner.js +50 -0
- package/src/plugins/pluginSkills.js +84 -0
- package/src/plugins/pluginSkills.ts +106 -0
- package/src/provisioning/capabilityProvisioner.d.ts +10 -0
- package/src/provisioning/capabilityProvisioner.js +5 -0
- package/src/provisioning/capabilityTools.js +2 -1
- package/src/sandbox.js +53 -2
- package/src/sessionJournal.js +471 -0
- package/src/sessionJournal.ts +496 -0
- package/src/sessionStore.js +3 -279
- package/src/skillsManager.js +18 -0
- package/src/subagentJournal.js +215 -0
- package/src/subagentJournal.ts +254 -0
- package/src/taskShape.js +179 -198
- package/src/taskShape.ts +259 -0
- package/src/toolPacks.js +6 -3
- package/src/tools/browserAutomator.js +45 -241
- package/src/tools/browserAutomator.ts +57 -0
- package/src/tools/deferredMcpSearch.js +2 -2
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/pluginManager.js +61 -0
- package/src/tools/registry.js +47 -53
- package/src/tools/subagentOrchestrator.js +73 -12
- package/src/tools/toolForge.js +47 -95
- package/src/tools/webFetch.js +183 -68
- package/src/tools/webFetch.ts +185 -0
- package/src/tools/webSearch.js +50 -75
- package/src/tools/webSearch.ts +56 -0
- package/src/types/contracts.js +8 -0
- package/src/types/contracts.ts +263 -0
- package/src/utils/paths.d.ts +3 -0
- package/src/version.d.ts +1 -0
- package/src/mcp/jsonRpc.js +0 -165
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Client,
|
|
6
|
+
SSEClientTransport,
|
|
7
|
+
SdkError,
|
|
8
|
+
SdkErrorCode,
|
|
9
|
+
SdkHttpError,
|
|
10
|
+
StreamableHTTPClientTransport,
|
|
11
|
+
UnauthorizedError,
|
|
12
|
+
type CallToolResult,
|
|
13
|
+
type Tool,
|
|
14
|
+
type Transport
|
|
15
|
+
} from '@modelcontextprotocol/client';
|
|
16
|
+
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
|
17
|
+
|
|
18
|
+
import { getPackageVersion } from '../version.js';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
21
|
+
const MAX_TIMEOUT_MS = 10 * 60_000;
|
|
22
|
+
const STDERR_LIMIT_BYTES = 8 * 1024;
|
|
23
|
+
const TOOL_OUTPUT_LIMIT_CHARS = 1_000_000;
|
|
24
|
+
|
|
25
|
+
export type McpConnectionState =
|
|
26
|
+
| 'disconnected'
|
|
27
|
+
| 'connecting'
|
|
28
|
+
| 'connected'
|
|
29
|
+
| 'failed'
|
|
30
|
+
| 'needs_auth'
|
|
31
|
+
| 'disabled';
|
|
32
|
+
|
|
33
|
+
export interface McpToolFilter {
|
|
34
|
+
include?: string[];
|
|
35
|
+
exclude?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface McpServerDefinition {
|
|
39
|
+
name: string;
|
|
40
|
+
command?: string;
|
|
41
|
+
args?: string[];
|
|
42
|
+
env?: Record<string, string>;
|
|
43
|
+
cwd?: string;
|
|
44
|
+
url?: string;
|
|
45
|
+
headers?: Record<string, string>;
|
|
46
|
+
enabled?: boolean;
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
protocol?: 'legacy' | 'auto';
|
|
49
|
+
tools?: McpToolFilter;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface McpConnectionStatus {
|
|
53
|
+
state: McpConnectionState;
|
|
54
|
+
error?: string;
|
|
55
|
+
transport?: 'stdio' | 'streamable-http' | 'sse';
|
|
56
|
+
connectedAt?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface McpToolDefinition {
|
|
60
|
+
name: string;
|
|
61
|
+
description: string;
|
|
62
|
+
inputSchema: Record<string, unknown>;
|
|
63
|
+
annotations?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface McpToolCallResult {
|
|
67
|
+
success: boolean;
|
|
68
|
+
output?: string;
|
|
69
|
+
error?: string;
|
|
70
|
+
structuredContent?: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type StatusListener = (status: McpConnectionStatus) => void;
|
|
74
|
+
type ToolsListener = (tools: McpToolDefinition[]) => void;
|
|
75
|
+
|
|
76
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
81
|
+
return isRecord(value) && Object.values(value).every(item => typeof item === 'string');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function errorMessage(error: unknown): string {
|
|
85
|
+
return error instanceof Error ? error.message : String(error);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isAuthenticationError(error: unknown): boolean {
|
|
89
|
+
if (error instanceof UnauthorizedError) return true;
|
|
90
|
+
if (error instanceof SdkHttpError) return error.status === 401;
|
|
91
|
+
return error instanceof SdkError && error.code === SdkErrorCode.ClientHttpAuthentication;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizedTimeout(value: number | undefined): number {
|
|
95
|
+
if (value === undefined) return DEFAULT_TIMEOUT_MS;
|
|
96
|
+
return Math.max(1_000, Math.min(MAX_TIMEOUT_MS, value));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateStringArray(value: unknown, label: string): string | null {
|
|
100
|
+
if (!Array.isArray(value)) return `${label} must be an array`;
|
|
101
|
+
if (!value.every(item => typeof item === 'string' && !item.includes('\0'))) {
|
|
102
|
+
return `${label} must contain only strings without NUL characters`;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeTool(tool: Tool): McpToolDefinition | null {
|
|
108
|
+
if (typeof tool.name !== 'string' || tool.name.trim().length === 0) return null;
|
|
109
|
+
const inputSchema = isRecord(tool.inputSchema)
|
|
110
|
+
? tool.inputSchema
|
|
111
|
+
: { type: 'object', properties: {} };
|
|
112
|
+
const normalized: McpToolDefinition = {
|
|
113
|
+
name: tool.name,
|
|
114
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
115
|
+
inputSchema
|
|
116
|
+
};
|
|
117
|
+
if (isRecord(tool.annotations)) normalized.annotations = tool.annotations;
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function describeContentBlock(block: unknown): string {
|
|
122
|
+
if (!isRecord(block) || typeof block.type !== 'string') return '';
|
|
123
|
+
if (block.type === 'text' && typeof block.text === 'string') return block.text;
|
|
124
|
+
|
|
125
|
+
if ((block.type === 'image' || block.type === 'audio') && typeof block.data === 'string') {
|
|
126
|
+
const mime = typeof block.mimeType === 'string' ? block.mimeType : 'application/octet-stream';
|
|
127
|
+
const estimatedBytes = Math.floor(block.data.length * 0.75);
|
|
128
|
+
return `[MCP ${block.type}: ${mime}, ${estimatedBytes} encoded bytes]`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (block.type === 'resource_link') {
|
|
132
|
+
const uri = typeof block.uri === 'string' ? block.uri : '(unknown URI)';
|
|
133
|
+
const name = typeof block.name === 'string' ? ` ${block.name}` : '';
|
|
134
|
+
return `[MCP resource link:${name} ${uri}]`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (block.type === 'resource' && isRecord(block.resource)) {
|
|
138
|
+
const resource = block.resource;
|
|
139
|
+
const uri = typeof resource.uri === 'string' ? resource.uri : '(unknown URI)';
|
|
140
|
+
if (typeof resource.text === 'string') return `[MCP resource ${uri}]\n${resource.text}`;
|
|
141
|
+
if (typeof resource.blob === 'string') {
|
|
142
|
+
const mime = typeof resource.mimeType === 'string' ? resource.mimeType : 'application/octet-stream';
|
|
143
|
+
return `[MCP resource ${uri}: ${mime}, ${Math.floor(resource.blob.length * 0.75)} encoded bytes]`;
|
|
144
|
+
}
|
|
145
|
+
return `[MCP resource ${uri}]`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
return JSON.stringify(block);
|
|
150
|
+
} catch {
|
|
151
|
+
return `[MCP ${block.type} content]`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function boundedOutput(output: string): string {
|
|
156
|
+
return output.length > TOOL_OUTPUT_LIMIT_CHARS
|
|
157
|
+
? `${output.slice(0, TOOL_OUTPUT_LIMIT_CHARS)}\n\n[MCP output truncated at ${TOOL_OUTPUT_LIMIT_CHARS} characters]`
|
|
158
|
+
: output;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function formatToolResult(result: CallToolResult): string {
|
|
162
|
+
const content = Array.isArray(result.content)
|
|
163
|
+
? result.content.map(describeContentBlock).filter(Boolean).join('\n\n')
|
|
164
|
+
: '';
|
|
165
|
+
let output = content;
|
|
166
|
+
if (!output && result.structuredContent !== undefined) {
|
|
167
|
+
output = JSON.stringify(result.structuredContent, null, 2);
|
|
168
|
+
}
|
|
169
|
+
if (!output) output = JSON.stringify(result, null, 2);
|
|
170
|
+
return boundedOutput(output);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatResourceContents(contents: unknown[]): string {
|
|
174
|
+
const output = contents.map(item => {
|
|
175
|
+
if (!isRecord(item)) return '';
|
|
176
|
+
const uri = typeof item.uri === 'string' ? item.uri : '(unknown URI)';
|
|
177
|
+
if (typeof item.text === 'string') return `[MCP resource ${uri}]\n${item.text}`;
|
|
178
|
+
if (typeof item.blob === 'string') {
|
|
179
|
+
const mime = typeof item.mimeType === 'string' ? item.mimeType : 'application/octet-stream';
|
|
180
|
+
return `[MCP resource ${uri}: ${mime}, ${Math.floor(item.blob.length * 0.75)} encoded bytes]`;
|
|
181
|
+
}
|
|
182
|
+
return `[MCP resource ${uri}]`;
|
|
183
|
+
}).filter(Boolean).join('\n\n');
|
|
184
|
+
return boundedOutput(output);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatPromptMessages(messages: unknown[]): string {
|
|
188
|
+
const output = messages.map(message => {
|
|
189
|
+
if (!isRecord(message)) return '';
|
|
190
|
+
const role = typeof message.role === 'string' ? message.role : 'unknown';
|
|
191
|
+
return `[${role}] ${describeContentBlock(message.content)}`;
|
|
192
|
+
}).filter(Boolean).join('\n\n');
|
|
193
|
+
return boundedOutput(output);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class McpConnectionError extends Error {
|
|
197
|
+
readonly state: Extract<McpConnectionState, 'failed' | 'needs_auth'>;
|
|
198
|
+
|
|
199
|
+
constructor(state: Extract<McpConnectionState, 'failed' | 'needs_auth'>, message: string, options?: ErrorOptions) {
|
|
200
|
+
super(message, options);
|
|
201
|
+
this.name = 'McpConnectionError';
|
|
202
|
+
this.state = state;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class McpClient {
|
|
207
|
+
readonly name: string;
|
|
208
|
+
readonly definition: McpServerDefinition;
|
|
209
|
+
readonly workspace: string;
|
|
210
|
+
tools: McpToolDefinition[] = [];
|
|
211
|
+
serverInfo: { name: string; version: string } | null = null;
|
|
212
|
+
instructions: string | null = null;
|
|
213
|
+
status: McpConnectionStatus = { state: 'disconnected' };
|
|
214
|
+
|
|
215
|
+
private client: Client | null = null;
|
|
216
|
+
private transport: Transport | null = null;
|
|
217
|
+
private stderrTail = '';
|
|
218
|
+
private closing = false;
|
|
219
|
+
private readonly onStatus: StatusListener | undefined;
|
|
220
|
+
private readonly onToolsChanged: ToolsListener | undefined;
|
|
221
|
+
|
|
222
|
+
constructor(
|
|
223
|
+
definition: McpServerDefinition,
|
|
224
|
+
options: { workspace?: string; onStatus?: StatusListener; onToolsChanged?: ToolsListener } = {}
|
|
225
|
+
) {
|
|
226
|
+
this.name = definition.name;
|
|
227
|
+
this.definition = definition;
|
|
228
|
+
this.workspace = path.resolve(options.workspace ?? process.cwd());
|
|
229
|
+
this.onStatus = options.onStatus;
|
|
230
|
+
this.onToolsChanged = options.onToolsChanged;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
static validateDefinition(definition: unknown): string | null {
|
|
234
|
+
if (!isRecord(definition)) return 'not an object';
|
|
235
|
+
if (typeof definition.name !== 'string' || !/^[a-z0-9_-]{1,48}$/i.test(definition.name)) {
|
|
236
|
+
return 'name must be 1-48 characters of [A-Za-z0-9_-]';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const hasStdio = typeof definition.command === 'string' && definition.command.trim().length > 0;
|
|
240
|
+
const hasHttp = typeof definition.url === 'string' && definition.url.trim().length > 0;
|
|
241
|
+
if (hasStdio === hasHttp) return 'exactly one of `command` (stdio) or `url` (HTTP) is required';
|
|
242
|
+
|
|
243
|
+
if (hasStdio) {
|
|
244
|
+
if ((definition.command as string).includes('\0')) return '`command` must not contain a NUL character';
|
|
245
|
+
if (definition.args !== undefined) {
|
|
246
|
+
const problem = validateStringArray(definition.args, '`args`');
|
|
247
|
+
if (problem) return problem;
|
|
248
|
+
}
|
|
249
|
+
if (definition.env !== undefined && !isStringRecord(definition.env)) {
|
|
250
|
+
return '`env` must be an object containing only string values';
|
|
251
|
+
}
|
|
252
|
+
if (definition.cwd !== undefined && (typeof definition.cwd !== 'string' || definition.cwd.includes('\0'))) {
|
|
253
|
+
return '`cwd` must be a string without NUL characters';
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (hasHttp) {
|
|
258
|
+
let parsed: URL;
|
|
259
|
+
try {
|
|
260
|
+
parsed = new URL(definition.url as string);
|
|
261
|
+
} catch {
|
|
262
|
+
return '`url` must be a valid URL';
|
|
263
|
+
}
|
|
264
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
265
|
+
return '`url` must use http or https';
|
|
266
|
+
}
|
|
267
|
+
if (parsed.username || parsed.password) return '`url` must not embed credentials; use headers or OAuth';
|
|
268
|
+
if (definition.headers !== undefined && !isStringRecord(definition.headers)) {
|
|
269
|
+
return '`headers` must be an object containing only string values';
|
|
270
|
+
}
|
|
271
|
+
if (isStringRecord(definition.headers)) {
|
|
272
|
+
for (const [key, value] of Object.entries(definition.headers)) {
|
|
273
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(key) || /[\r\n]/.test(value)) {
|
|
274
|
+
return '`headers` contains an invalid name or value';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (definition.timeoutMs !== undefined &&
|
|
281
|
+
(typeof definition.timeoutMs !== 'number' || !Number.isFinite(definition.timeoutMs) || definition.timeoutMs < 1_000 || definition.timeoutMs > MAX_TIMEOUT_MS)) {
|
|
282
|
+
return `\`timeoutMs\` must be between 1000 and ${MAX_TIMEOUT_MS}`;
|
|
283
|
+
}
|
|
284
|
+
if (definition.protocol !== undefined && definition.protocol !== 'legacy' && definition.protocol !== 'auto') {
|
|
285
|
+
return '`protocol` must be "legacy" or "auto"';
|
|
286
|
+
}
|
|
287
|
+
if (definition.enabled !== undefined && typeof definition.enabled !== 'boolean') {
|
|
288
|
+
return '`enabled` must be a boolean';
|
|
289
|
+
}
|
|
290
|
+
if (definition.tools !== undefined) {
|
|
291
|
+
if (!isRecord(definition.tools)) return '`tools` must be an object';
|
|
292
|
+
for (const key of ['include', 'exclude'] as const) {
|
|
293
|
+
if (definition.tools[key] !== undefined) {
|
|
294
|
+
const problem = validateStringArray(definition.tools[key], `\`tools.${key}\``);
|
|
295
|
+
if (problem) return problem;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async connect(): Promise<this> {
|
|
303
|
+
const problem = McpClient.validateDefinition(this.definition);
|
|
304
|
+
if (problem) throw new McpConnectionError('failed', `invalid server definition: ${problem}`);
|
|
305
|
+
if (this.definition.enabled === false) {
|
|
306
|
+
this.setStatus({ state: 'disabled' });
|
|
307
|
+
return this;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
this.closing = false;
|
|
311
|
+
this.setStatus({ state: 'connecting' });
|
|
312
|
+
const timeout = normalizedTimeout(this.definition.timeoutMs);
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
if (this.definition.url) {
|
|
316
|
+
await this.connectRemote(timeout);
|
|
317
|
+
} else {
|
|
318
|
+
await this.connectStdio(timeout);
|
|
319
|
+
}
|
|
320
|
+
await this.refreshTools();
|
|
321
|
+
return this;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
await this.dispose();
|
|
324
|
+
const state = isAuthenticationError(error) ? 'needs_auth' : 'failed';
|
|
325
|
+
const suffix = this.stderrTail.trim() ? `: ${this.stderrTail.trim().slice(-600)}` : '';
|
|
326
|
+
const message = `${errorMessage(error)}${suffix}`;
|
|
327
|
+
this.setStatus({ state, error: message });
|
|
328
|
+
throw new McpConnectionError(state, message, { cause: error });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async refreshTools(): Promise<McpToolDefinition[]> {
|
|
333
|
+
if (!this.client) throw new Error('MCP client is not connected');
|
|
334
|
+
if (!this.client.getServerCapabilities()?.tools) {
|
|
335
|
+
this.replaceTools([]);
|
|
336
|
+
return this.tools;
|
|
337
|
+
}
|
|
338
|
+
const result = await this.client.listTools(undefined, {
|
|
339
|
+
timeout: normalizedTimeout(this.definition.timeoutMs),
|
|
340
|
+
cacheMode: 'refresh'
|
|
341
|
+
});
|
|
342
|
+
this.replaceTools(result.tools);
|
|
343
|
+
return this.tools;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async callTool(toolName: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<McpToolCallResult> {
|
|
347
|
+
if (!this.client || this.status.state !== 'connected') {
|
|
348
|
+
return { success: false, error: `MCP server "${this.name}" is not connected` };
|
|
349
|
+
}
|
|
350
|
+
const toolDefinition = this.tools.find(tool => tool.name === toolName);
|
|
351
|
+
if (!toolDefinition) return { success: false, error: `MCP server "${this.name}" does not advertise tool "${toolName}"` };
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const options: Parameters<Client['callTool']>[1] = {
|
|
355
|
+
timeout: normalizedTimeout(this.definition.timeoutMs),
|
|
356
|
+
resetTimeoutOnProgress: true,
|
|
357
|
+
maxTotalTimeout: MAX_TIMEOUT_MS,
|
|
358
|
+
onprogress: () => {}
|
|
359
|
+
};
|
|
360
|
+
if (signal) options.signal = signal;
|
|
361
|
+
const result = await this.client.callTool({ name: toolName, arguments: args }, options);
|
|
362
|
+
const output = formatToolResult(result);
|
|
363
|
+
if (result.isError) return { success: false, error: output || 'the MCP server reported an error' };
|
|
364
|
+
|
|
365
|
+
const response: McpToolCallResult = { success: true, output };
|
|
366
|
+
if (result.structuredContent !== undefined) response.structuredContent = result.structuredContent;
|
|
367
|
+
return response;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return { success: false, error: `MCP server "${this.name}": ${errorMessage(error)}` };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
supportsResources(): boolean {
|
|
374
|
+
return Boolean(this.client?.getServerCapabilities()?.resources);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
supportsPrompts(): boolean {
|
|
378
|
+
return Boolean(this.client?.getServerCapabilities()?.prompts);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async listResources(signal?: AbortSignal): Promise<McpToolCallResult> {
|
|
382
|
+
if (!this.client || !this.supportsResources()) {
|
|
383
|
+
return { success: false, error: `MCP server "${this.name}" does not advertise resources` };
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
const options: Parameters<Client['listResources']>[1] = {
|
|
387
|
+
timeout: normalizedTimeout(this.definition.timeoutMs),
|
|
388
|
+
cacheMode: 'refresh'
|
|
389
|
+
};
|
|
390
|
+
if (signal) options.signal = signal;
|
|
391
|
+
const [resourcesResult, templatesResult] = await Promise.allSettled([
|
|
392
|
+
this.client.listResources(undefined, options),
|
|
393
|
+
this.client.listResourceTemplates(undefined, options)
|
|
394
|
+
]);
|
|
395
|
+
if (resourcesResult.status === 'rejected' && templatesResult.status === 'rejected') {
|
|
396
|
+
throw resourcesResult.reason;
|
|
397
|
+
}
|
|
398
|
+
const warnings: string[] = [];
|
|
399
|
+
if (resourcesResult.status === 'rejected') warnings.push(`resources/list: ${errorMessage(resourcesResult.reason)}`);
|
|
400
|
+
if (templatesResult.status === 'rejected') warnings.push(`resources/templates/list: ${errorMessage(templatesResult.reason)}`);
|
|
401
|
+
const payload: Record<string, unknown> = {
|
|
402
|
+
resources: resourcesResult.status === 'fulfilled' ? resourcesResult.value.resources : [],
|
|
403
|
+
resourceTemplates: templatesResult.status === 'fulfilled' ? templatesResult.value.resourceTemplates : []
|
|
404
|
+
};
|
|
405
|
+
if (warnings.length > 0) payload.warnings = warnings;
|
|
406
|
+
return {
|
|
407
|
+
success: true,
|
|
408
|
+
output: boundedOutput(JSON.stringify(payload, null, 2))
|
|
409
|
+
};
|
|
410
|
+
} catch (error) {
|
|
411
|
+
return { success: false, error: `MCP server "${this.name}": ${errorMessage(error)}` };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async readResource(uri: string, signal?: AbortSignal): Promise<McpToolCallResult> {
|
|
416
|
+
if (!this.client || !this.supportsResources()) {
|
|
417
|
+
return { success: false, error: `MCP server "${this.name}" does not advertise resources` };
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const options: Parameters<Client['readResource']>[1] = {
|
|
421
|
+
timeout: normalizedTimeout(this.definition.timeoutMs),
|
|
422
|
+
cacheMode: 'refresh'
|
|
423
|
+
};
|
|
424
|
+
if (signal) options.signal = signal;
|
|
425
|
+
const result = await this.client.readResource({ uri }, options);
|
|
426
|
+
return { success: true, output: formatResourceContents(result.contents) };
|
|
427
|
+
} catch (error) {
|
|
428
|
+
return { success: false, error: `MCP server "${this.name}": ${errorMessage(error)}` };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async listPrompts(signal?: AbortSignal): Promise<McpToolCallResult> {
|
|
433
|
+
if (!this.client || !this.supportsPrompts()) {
|
|
434
|
+
return { success: false, error: `MCP server "${this.name}" does not advertise prompts` };
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
const options: Parameters<Client['listPrompts']>[1] = {
|
|
438
|
+
timeout: normalizedTimeout(this.definition.timeoutMs),
|
|
439
|
+
cacheMode: 'refresh'
|
|
440
|
+
};
|
|
441
|
+
if (signal) options.signal = signal;
|
|
442
|
+
const result = await this.client.listPrompts(undefined, options);
|
|
443
|
+
return { success: true, output: boundedOutput(JSON.stringify(result.prompts, null, 2)) };
|
|
444
|
+
} catch (error) {
|
|
445
|
+
return { success: false, error: `MCP server "${this.name}": ${errorMessage(error)}` };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async getPrompt(name: string, args: Record<string, string>, signal?: AbortSignal): Promise<McpToolCallResult> {
|
|
450
|
+
if (!this.client || !this.supportsPrompts()) {
|
|
451
|
+
return { success: false, error: `MCP server "${this.name}" does not advertise prompts` };
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const options: Parameters<Client['getPrompt']>[1] = { timeout: normalizedTimeout(this.definition.timeoutMs) };
|
|
455
|
+
if (signal) options.signal = signal;
|
|
456
|
+
const result = await this.client.getPrompt({ name, arguments: args }, options);
|
|
457
|
+
const text = formatPromptMessages(result.messages);
|
|
458
|
+
return { success: true, output: result.description ? `${result.description}\n\n${text}` : text };
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return { success: false, error: `MCP server "${this.name}": ${errorMessage(error)}` };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async close(): Promise<void> {
|
|
465
|
+
this.closing = true;
|
|
466
|
+
await this.dispose();
|
|
467
|
+
this.tools = [];
|
|
468
|
+
this.setStatus({ state: 'disconnected' });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private makeClient(): Client {
|
|
472
|
+
const client = new Client(
|
|
473
|
+
{ name: 'causyne-agent', version: getPackageVersion() },
|
|
474
|
+
{
|
|
475
|
+
capabilities: { roots: {} },
|
|
476
|
+
versionNegotiation: { mode: this.definition.protocol ?? 'legacy' },
|
|
477
|
+
listMaxPages: 64,
|
|
478
|
+
listChanged: {
|
|
479
|
+
tools: {
|
|
480
|
+
onChanged: (error, result) => {
|
|
481
|
+
if (error) {
|
|
482
|
+
this.setStatus({ state: 'failed', error: `tool refresh failed: ${error.message}` });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.replaceTools(result ?? []);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
);
|
|
491
|
+
client.setRequestHandler('roots/list', async () => ({
|
|
492
|
+
roots: [{ uri: pathToFileURL(this.workspace).href, name: path.basename(this.workspace) || this.workspace }]
|
|
493
|
+
}));
|
|
494
|
+
client.onclose = () => {
|
|
495
|
+
if (!this.closing) this.setStatus({ state: 'failed', error: 'connection closed by MCP server' });
|
|
496
|
+
};
|
|
497
|
+
client.onerror = error => {
|
|
498
|
+
if (!this.closing) this.setStatus({ state: 'failed', error: error.message });
|
|
499
|
+
};
|
|
500
|
+
return client;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private async connectStdio(timeout: number): Promise<void> {
|
|
504
|
+
const definition = this.definition;
|
|
505
|
+
if (!definition.command) throw new Error('stdio command is missing');
|
|
506
|
+
const cwd = definition.cwd
|
|
507
|
+
? path.resolve(this.workspace, definition.cwd)
|
|
508
|
+
: this.workspace;
|
|
509
|
+
const transport = new StdioClientTransport({
|
|
510
|
+
command: definition.command,
|
|
511
|
+
args: definition.args ?? [],
|
|
512
|
+
env: { ...getDefaultEnvironment(), ...(definition.env ?? {}) },
|
|
513
|
+
cwd,
|
|
514
|
+
stderr: 'pipe'
|
|
515
|
+
});
|
|
516
|
+
transport.stderr?.on('data', chunk => {
|
|
517
|
+
this.stderrTail = (this.stderrTail + String(chunk)).slice(-STDERR_LIMIT_BYTES);
|
|
518
|
+
});
|
|
519
|
+
const client = this.makeClient();
|
|
520
|
+
this.transport = transport;
|
|
521
|
+
this.client = client;
|
|
522
|
+
await client.connect(transport, { timeout });
|
|
523
|
+
this.captureServer(client, 'stdio');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private async connectRemote(timeout: number): Promise<void> {
|
|
527
|
+
const definition = this.definition;
|
|
528
|
+
if (!definition.url) throw new Error('HTTP URL is missing');
|
|
529
|
+
const url = new URL(definition.url);
|
|
530
|
+
const requestInit = definition.headers ? { headers: definition.headers } : undefined;
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
const transport = new StreamableHTTPClientTransport(url, requestInit ? { requestInit } : undefined);
|
|
534
|
+
const client = this.makeClient();
|
|
535
|
+
this.transport = transport;
|
|
536
|
+
this.client = client;
|
|
537
|
+
await client.connect(transport, { timeout });
|
|
538
|
+
this.captureServer(client, 'streamable-http');
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if (isAuthenticationError(error)) throw error;
|
|
541
|
+
await this.dispose();
|
|
542
|
+
const transport = new SSEClientTransport(url, requestInit ? { requestInit } : undefined);
|
|
543
|
+
const client = this.makeClient();
|
|
544
|
+
this.transport = transport;
|
|
545
|
+
this.client = client;
|
|
546
|
+
await client.connect(transport, { timeout });
|
|
547
|
+
this.captureServer(client, 'sse');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private captureServer(client: Client, transport: McpConnectionStatus['transport']): void {
|
|
552
|
+
const server = client.getServerVersion();
|
|
553
|
+
this.serverInfo = server ? { name: server.name, version: server.version } : null;
|
|
554
|
+
this.instructions = client.getInstructions()?.trim() || null;
|
|
555
|
+
const status: McpConnectionStatus = { state: 'connected', connectedAt: Date.now() };
|
|
556
|
+
if (transport) status.transport = transport;
|
|
557
|
+
this.setStatus(status);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private replaceTools(tools: Tool[]): void {
|
|
561
|
+
this.tools = tools.map(normalizeTool).filter((tool): tool is McpToolDefinition => tool !== null);
|
|
562
|
+
this.onToolsChanged?.([...this.tools]);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private setStatus(status: McpConnectionStatus): void {
|
|
566
|
+
this.status = status;
|
|
567
|
+
this.onStatus?.({ ...status });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private async dispose(): Promise<void> {
|
|
571
|
+
const client = this.client;
|
|
572
|
+
const transport = this.transport;
|
|
573
|
+
this.client = null;
|
|
574
|
+
this.transport = null;
|
|
575
|
+
if (transport instanceof StreamableHTTPClientTransport) {
|
|
576
|
+
try { await transport.terminateSession(); } catch {}
|
|
577
|
+
}
|
|
578
|
+
if (client) {
|
|
579
|
+
try { await client.close(); } catch {}
|
|
580
|
+
} else if (transport) {
|
|
581
|
+
try { await transport.close(); } catch {}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|