tinker-agent 1.0.65
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 +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- package/src/tui/view-file.ts +122 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createUuidV7 } from "./uuid-v7";
|
|
2
|
+
|
|
3
|
+
export type RuntimeId<Name extends string> = string & {
|
|
4
|
+
readonly __runtimeId: Name;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type SessionId = RuntimeId<"session">;
|
|
8
|
+
export type TurnId = RuntimeId<"turn">;
|
|
9
|
+
export type IterationId = RuntimeId<"iteration">;
|
|
10
|
+
export type ToolCallId = RuntimeId<"tool-call">;
|
|
11
|
+
export type MessageId = RuntimeId<"message">;
|
|
12
|
+
export type ProtocolFrameId = RuntimeId<"protocol-frame">;
|
|
13
|
+
export type ContextRevisionId = RuntimeId<"context-revision">;
|
|
14
|
+
|
|
15
|
+
export type RuntimeIdFactory = {
|
|
16
|
+
createSessionId(): SessionId;
|
|
17
|
+
createTurnId(): TurnId;
|
|
18
|
+
createIterationId(): IterationId;
|
|
19
|
+
createToolCallId(): ToolCallId;
|
|
20
|
+
createMessageId(): MessageId;
|
|
21
|
+
createProtocolFrameId(): ProtocolFrameId;
|
|
22
|
+
createContextRevisionId(): ContextRevisionId;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const runtimeIdFactory: RuntimeIdFactory = {
|
|
26
|
+
createSessionId: () => createUuidV7() as SessionId,
|
|
27
|
+
createTurnId: () => createUuidV7() as TurnId,
|
|
28
|
+
createIterationId: () => createUuidV7() as IterationId,
|
|
29
|
+
createToolCallId: () => createUuidV7() as ToolCallId,
|
|
30
|
+
createMessageId: () => createUuidV7() as MessageId,
|
|
31
|
+
createProtocolFrameId: () => createUuidV7() as ProtocolFrameId,
|
|
32
|
+
createContextRevisionId: () => createUuidV7() as ContextRevisionId,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const canonicalUuidV7Pattern =
|
|
36
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
37
|
+
|
|
38
|
+
export class SessionIdParseError extends Error {
|
|
39
|
+
readonly code = "SESSION_ID_INVALID" as const;
|
|
40
|
+
|
|
41
|
+
constructor(value: string) {
|
|
42
|
+
super(`Invalid session ID: ${JSON.stringify(value)}.`);
|
|
43
|
+
this.name = "SessionIdParseError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class MessageIdParseError extends Error {
|
|
48
|
+
readonly code = "MESSAGE_ID_INVALID" as const;
|
|
49
|
+
|
|
50
|
+
constructor(value: string) {
|
|
51
|
+
super(`Invalid message ID: ${JSON.stringify(value)}.`);
|
|
52
|
+
this.name = "MessageIdParseError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseSessionId(value: string): SessionId {
|
|
57
|
+
if (!canonicalUuidV7Pattern.test(value)) {
|
|
58
|
+
throw new SessionIdParseError(value);
|
|
59
|
+
}
|
|
60
|
+
return value as SessionId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseMessageId(value: string): MessageId {
|
|
64
|
+
if (!canonicalUuidV7Pattern.test(value)) {
|
|
65
|
+
throw new MessageIdParseError(value);
|
|
66
|
+
}
|
|
67
|
+
return value as MessageId;
|
|
68
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export const PROJECT_INSTRUCTIONS_MAX_BYTES = 64 * 1024;
|
|
7
|
+
|
|
8
|
+
export type ProjectInstructionFileName = "CLAUDE.md" | "AGENTS.md";
|
|
9
|
+
|
|
10
|
+
export type LoadedProjectInstruction = {
|
|
11
|
+
fileName: ProjectInstructionFileName;
|
|
12
|
+
absolutePath: string;
|
|
13
|
+
content: string;
|
|
14
|
+
contentSha256: string;
|
|
15
|
+
byteLength: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ProjectInstructionsSnapshot = {
|
|
19
|
+
workspaceRoot: string;
|
|
20
|
+
instruction?: LoadedProjectInstruction;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ProjectInstructionManifest = {
|
|
24
|
+
path: ProjectInstructionFileName;
|
|
25
|
+
byteLength: number;
|
|
26
|
+
sha256: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const PROJECT_INSTRUCTION_FILE_NAMES: readonly ProjectInstructionFileName[] = [
|
|
30
|
+
"AGENTS.md",
|
|
31
|
+
"CLAUDE.md",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export async function loadProjectInstructions(
|
|
35
|
+
workspaceRoot: string,
|
|
36
|
+
): Promise<ProjectInstructionsSnapshot> {
|
|
37
|
+
const canonicalRoot = await realpath(workspaceRoot);
|
|
38
|
+
for (const fileName of PROJECT_INSTRUCTION_FILE_NAMES) {
|
|
39
|
+
const instruction = await loadCandidate(canonicalRoot, fileName);
|
|
40
|
+
if (instruction !== undefined) {
|
|
41
|
+
return { workspaceRoot: canonicalRoot, instruction };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { workspaceRoot: canonicalRoot };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildSystemPrompt(input: {
|
|
48
|
+
workspaceRoot: string;
|
|
49
|
+
runtimeInstructions: string;
|
|
50
|
+
projectInstructions: ProjectInstructionsSnapshot;
|
|
51
|
+
}): string {
|
|
52
|
+
if (input.runtimeInstructions.trim() === "") {
|
|
53
|
+
throw new Error("Runtime instructions must not be empty.");
|
|
54
|
+
}
|
|
55
|
+
if (input.projectInstructions.workspaceRoot !== input.workspaceRoot) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
"Project instructions snapshot does not match the current workspace root.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const runtime = wrapTextBlock(
|
|
62
|
+
"tinker_runtime_instructions",
|
|
63
|
+
input.runtimeInstructions,
|
|
64
|
+
);
|
|
65
|
+
const instruction = input.projectInstructions.instruction;
|
|
66
|
+
if (instruction === undefined) {
|
|
67
|
+
return runtime;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const content =
|
|
71
|
+
instruction.content + (instruction.content.endsWith("\n") ? "" : "\n");
|
|
72
|
+
const project = `<project_instructions>
|
|
73
|
+
The following file contains trusted project instructions for this workspace.
|
|
74
|
+
They do not override Tinker's runtime, tool protocol, or safety constraints.
|
|
75
|
+
|
|
76
|
+
<instruction_file path="${instruction.fileName}">
|
|
77
|
+
${content}</instruction_file>
|
|
78
|
+
</project_instructions>`;
|
|
79
|
+
return `${runtime}\n\n${project}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function projectInstructionManifest(
|
|
83
|
+
snapshot: ProjectInstructionsSnapshot,
|
|
84
|
+
): ProjectInstructionManifest | undefined {
|
|
85
|
+
const instruction = snapshot.instruction;
|
|
86
|
+
return instruction === undefined
|
|
87
|
+
? undefined
|
|
88
|
+
: {
|
|
89
|
+
path: instruction.fileName,
|
|
90
|
+
byteLength: instruction.byteLength,
|
|
91
|
+
sha256: instruction.contentSha256,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function loadCandidate(
|
|
96
|
+
workspaceRoot: string,
|
|
97
|
+
fileName: ProjectInstructionFileName,
|
|
98
|
+
): Promise<LoadedProjectInstruction | undefined> {
|
|
99
|
+
const absolutePath = path.join(workspaceRoot, fileName);
|
|
100
|
+
let handle;
|
|
101
|
+
try {
|
|
102
|
+
handle = await open(absolutePath, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!isErrno(error, "ENOENT")) {
|
|
105
|
+
throw loadError(fileName, error);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await lstat(absolutePath);
|
|
109
|
+
} catch (lstatError) {
|
|
110
|
+
if (isErrno(lstatError, "ENOENT")) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
throw loadError(fileName, lstatError);
|
|
114
|
+
}
|
|
115
|
+
throw loadError(fileName, error);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const targetPath = await realpath(absolutePath);
|
|
120
|
+
if (!isWithinWorkspace(workspaceRoot, targetPath)) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Project instruction ${fileName} resolves outside the workspace root.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const stats = await handle.stat();
|
|
127
|
+
if (!stats.isFile()) {
|
|
128
|
+
throw new Error(`Project instruction ${fileName} must be a regular file.`);
|
|
129
|
+
}
|
|
130
|
+
const targetStats = await stat(targetPath);
|
|
131
|
+
if (targetStats.dev !== stats.dev || targetStats.ino !== stats.ino) {
|
|
132
|
+
throw new Error(`Project instruction ${fileName} changed while being opened.`);
|
|
133
|
+
}
|
|
134
|
+
if (stats.size > PROJECT_INSTRUCTIONS_MAX_BYTES) {
|
|
135
|
+
throw tooLargeError(fileName, stats.size);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const bytes = await readBounded(handle, PROJECT_INSTRUCTIONS_MAX_BYTES);
|
|
139
|
+
if (bytes.byteLength > PROJECT_INSTRUCTIONS_MAX_BYTES) {
|
|
140
|
+
throw tooLargeError(fileName, bytes.byteLength);
|
|
141
|
+
}
|
|
142
|
+
if (bytes.includes(0)) {
|
|
143
|
+
throw new Error(`Project instruction ${fileName} contains a NUL byte.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let content: string;
|
|
147
|
+
try {
|
|
148
|
+
content = new TextDecoder("utf-8", {
|
|
149
|
+
fatal: true,
|
|
150
|
+
ignoreBOM: true,
|
|
151
|
+
}).decode(bytes);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw new Error(`Project instruction ${fileName} is not valid UTF-8.`, {
|
|
154
|
+
cause: error,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (content.trim() === "") {
|
|
158
|
+
throw new Error(`Project instruction ${fileName} must not be empty.`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
fileName,
|
|
163
|
+
absolutePath,
|
|
164
|
+
content,
|
|
165
|
+
contentSha256: createHash("sha256").update(bytes).digest("hex"),
|
|
166
|
+
byteLength: bytes.byteLength,
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (isProjectInstructionError(error, fileName)) {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
throw loadError(fileName, error);
|
|
173
|
+
} finally {
|
|
174
|
+
await handle.close().catch((error: unknown) => {
|
|
175
|
+
throw loadError(fileName, error);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function readBounded(
|
|
181
|
+
handle: Awaited<ReturnType<typeof open>>,
|
|
182
|
+
maxBytes: number,
|
|
183
|
+
): Promise<Buffer> {
|
|
184
|
+
const chunks: Buffer[] = [];
|
|
185
|
+
let total = 0;
|
|
186
|
+
while (total <= maxBytes) {
|
|
187
|
+
const chunk = Buffer.allocUnsafe(Math.min(8192, maxBytes + 1 - total));
|
|
188
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null);
|
|
189
|
+
if (bytesRead === 0) {
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
193
|
+
total += bytesRead;
|
|
194
|
+
}
|
|
195
|
+
return Buffer.concat(chunks, total);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function wrapTextBlock(tag: string, content: string): string {
|
|
199
|
+
return `<${tag}>\n${content}${content.endsWith("\n") ? "" : "\n"}</${tag}>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isWithinWorkspace(workspaceRoot: string, targetPath: string): boolean {
|
|
203
|
+
const relative = path.relative(workspaceRoot, targetPath);
|
|
204
|
+
return (
|
|
205
|
+
relative !== ".." &&
|
|
206
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
207
|
+
!path.isAbsolute(relative)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function tooLargeError(fileName: ProjectInstructionFileName, actual: number): Error {
|
|
212
|
+
return new Error(
|
|
213
|
+
`Project instruction ${fileName} is ${actual} bytes; the limit is ${PROJECT_INSTRUCTIONS_MAX_BYTES} bytes.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function loadError(fileName: ProjectInstructionFileName, cause: unknown): Error {
|
|
218
|
+
return new Error(
|
|
219
|
+
`Failed to load project instruction ${fileName}: ${errorMessage(cause)}`,
|
|
220
|
+
{ cause },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isProjectInstructionError(
|
|
225
|
+
error: unknown,
|
|
226
|
+
fileName: ProjectInstructionFileName,
|
|
227
|
+
): error is Error {
|
|
228
|
+
return error instanceof Error && error.message.includes(`instruction ${fileName}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isErrno(error: unknown, code: string): boolean {
|
|
232
|
+
return (
|
|
233
|
+
typeof error === "object" &&
|
|
234
|
+
error !== null &&
|
|
235
|
+
"code" in error &&
|
|
236
|
+
error.code === code
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function errorMessage(error: unknown): string {
|
|
241
|
+
return error instanceof Error ? error.message : String(error);
|
|
242
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export type McpServerConfig = {
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type McpConfig = {
|
|
12
|
+
servers: Map<string, McpServerConfig>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SERVER_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
16
|
+
|
|
17
|
+
export function mcpConfigPath(workspaceRoot: string): string {
|
|
18
|
+
return path.join(workspaceRoot, ".mcp.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function loadMcpConfig(
|
|
22
|
+
workspaceRoot: string,
|
|
23
|
+
): Promise<McpConfig | undefined> {
|
|
24
|
+
const configPath = mcpConfigPath(workspaceRoot);
|
|
25
|
+
let raw: string;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(configPath, "utf8");
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (isFileNotFound(error)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Failed to read MCP config at ${configPath}: ${errorMessage(error)}`,
|
|
36
|
+
{ cause: error },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return parseMcpConfig(raw, configPath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseMcpConfig(raw: string, sourcePath: string): McpConfig {
|
|
44
|
+
let json: unknown;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
json = JSON.parse(raw);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Invalid JSON in MCP config ${sourcePath}: ${errorMessage(error)}`,
|
|
51
|
+
{ cause: error },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isRecord(json)) {
|
|
56
|
+
throw new Error(`MCP config ${sourcePath} must be a JSON object.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const mcpServers = json.mcpServers;
|
|
60
|
+
|
|
61
|
+
if (mcpServers === undefined) {
|
|
62
|
+
return { servers: new Map() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!isRecord(mcpServers)) {
|
|
66
|
+
throw new Error(`MCP config ${sourcePath}: "mcpServers" must be an object.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const servers = new Map<string, McpServerConfig>();
|
|
70
|
+
|
|
71
|
+
for (const [serverName, serverValue] of Object.entries(mcpServers)) {
|
|
72
|
+
servers.set(serverName, parseServerConfig(serverName, serverValue, sourcePath));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { servers };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseServerConfig(
|
|
79
|
+
serverName: string,
|
|
80
|
+
value: unknown,
|
|
81
|
+
sourcePath: string,
|
|
82
|
+
): McpServerConfig {
|
|
83
|
+
const where = `MCP config ${sourcePath}: server "${serverName}"`;
|
|
84
|
+
|
|
85
|
+
if (!SERVER_NAME_PATTERN.test(serverName) || serverName.includes("__")) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${where} has an invalid name. Server names must match ${SERVER_NAME_PATTERN} and must not contain "__".`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!isRecord(value)) {
|
|
92
|
+
throw new Error(`${where} must be an object.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (value.type !== undefined && value.type !== "stdio") {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${where} has unsupported type ${JSON.stringify(value.type)}. Only "stdio" servers are supported.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof value.command !== "string" || value.command.trim() === "") {
|
|
102
|
+
throw new Error(`${where} requires a non-empty string "command".`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const args = value.args ?? [];
|
|
106
|
+
|
|
107
|
+
if (!Array.isArray(args) || !args.every((item) => typeof item === "string")) {
|
|
108
|
+
throw new Error(`${where}: "args" must be an array of strings.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const env = value.env ?? {};
|
|
112
|
+
|
|
113
|
+
if (!isRecord(env) || !Object.values(env).every((item) => typeof item === "string")) {
|
|
114
|
+
throw new Error(`${where}: "env" must be an object mapping strings to strings.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (value.cwd !== undefined && typeof value.cwd !== "string") {
|
|
118
|
+
throw new Error(`${where}: "cwd" must be a string.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
command: value.command,
|
|
123
|
+
args,
|
|
124
|
+
env: env as Record<string, string>,
|
|
125
|
+
cwd: value.cwd,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isFileNotFound(error: unknown): boolean {
|
|
130
|
+
return (
|
|
131
|
+
typeof error === "object" &&
|
|
132
|
+
error !== null &&
|
|
133
|
+
"code" in error &&
|
|
134
|
+
error.code === "ENOENT"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
139
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function errorMessage(error: unknown): string {
|
|
143
|
+
return error instanceof Error ? error.message : String(error);
|
|
144
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import {
|
|
3
|
+
StdioClientTransport,
|
|
4
|
+
getDefaultEnvironment,
|
|
5
|
+
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
|
+
import type { RuntimeSessionContext } from "../agent/runtime-session";
|
|
7
|
+
import type { ToolExecutor } from "../tools/types";
|
|
8
|
+
import type { McpConfig, McpServerConfig } from "./mcp-config";
|
|
9
|
+
import { createMcpToolExecutor } from "./mcp-tool-executor";
|
|
10
|
+
|
|
11
|
+
const STDERR_TAIL_MAX_CHARS = 2_000;
|
|
12
|
+
|
|
13
|
+
export type McpClientConnection = {
|
|
14
|
+
client: Client;
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type McpClientFactory = (
|
|
19
|
+
serverName: string,
|
|
20
|
+
serverConfig: McpServerConfig,
|
|
21
|
+
) => Promise<McpClientConnection>;
|
|
22
|
+
|
|
23
|
+
export type McpManager = {
|
|
24
|
+
executors: ToolExecutor[];
|
|
25
|
+
dispose(): Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CreateMcpManagerOptions = {
|
|
29
|
+
config: McpConfig;
|
|
30
|
+
runtimeSession: RuntimeSessionContext;
|
|
31
|
+
clientFactory?: McpClientFactory;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
maxObservationChars?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function createMcpManager(
|
|
37
|
+
options: CreateMcpManagerOptions,
|
|
38
|
+
): Promise<McpManager> {
|
|
39
|
+
const clientFactory = options.clientFactory ?? stdioClientFactory;
|
|
40
|
+
const timeoutMs =
|
|
41
|
+
options.timeoutMs ?? parsePositiveIntegerEnv("TINKER_MCP_TIMEOUT_MS");
|
|
42
|
+
const maxObservationChars =
|
|
43
|
+
options.maxObservationChars ??
|
|
44
|
+
parsePositiveIntegerEnv("TINKER_MCP_MAX_OBSERVATION_CHARS");
|
|
45
|
+
const connections: McpClientConnection[] = [];
|
|
46
|
+
const executors: ToolExecutor[] = [];
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
for (const [serverName, serverConfig] of options.config.servers) {
|
|
50
|
+
let connection: McpClientConnection;
|
|
51
|
+
let tools;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
connection = await clientFactory(serverName, serverConfig);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
await options.runtimeSession.append({
|
|
57
|
+
type: "mcp.server.failed",
|
|
58
|
+
sessionId: options.runtimeSession.sessionId,
|
|
59
|
+
data: {
|
|
60
|
+
serverName,
|
|
61
|
+
error: error instanceof Error ? error.message : String(error),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
tools = (await connection.client.listTools()).tools;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
try {
|
|
71
|
+
await connection.close();
|
|
72
|
+
} catch (closeError) {
|
|
73
|
+
throw new AggregateError(
|
|
74
|
+
[error, closeError],
|
|
75
|
+
`Failed to inspect and close MCP server ${serverName}.`,
|
|
76
|
+
{ cause: closeError },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
await options.runtimeSession.append({
|
|
80
|
+
type: "mcp.server.failed",
|
|
81
|
+
sessionId: options.runtimeSession.sessionId,
|
|
82
|
+
data: {
|
|
83
|
+
serverName,
|
|
84
|
+
error: error instanceof Error ? error.message : String(error),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
connections.push(connection);
|
|
91
|
+
|
|
92
|
+
const seenToolNames = new Set<string>();
|
|
93
|
+
|
|
94
|
+
for (const tool of tools) {
|
|
95
|
+
if (seenToolNames.has(tool.name)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
seenToolNames.add(tool.name);
|
|
100
|
+
executors.push(
|
|
101
|
+
createMcpToolExecutor({
|
|
102
|
+
client: connection.client,
|
|
103
|
+
serverName,
|
|
104
|
+
tool,
|
|
105
|
+
timeoutMs,
|
|
106
|
+
maxObservationChars,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await options.runtimeSession.append({
|
|
112
|
+
type: "mcp.server.connected",
|
|
113
|
+
sessionId: options.runtimeSession.sessionId,
|
|
114
|
+
data: { serverName, toolCount: seenToolNames.size },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const errors = [error];
|
|
119
|
+
await closeConnections(connections, errors);
|
|
120
|
+
if (errors.length === 1) {
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
throw new AggregateError(errors, "MCP manager initialization failed.", {
|
|
124
|
+
cause: error,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let disposePromise: Promise<void> | undefined;
|
|
129
|
+
return {
|
|
130
|
+
executors,
|
|
131
|
+
dispose(): Promise<void> {
|
|
132
|
+
disposePromise ??= disposeConnections(connections);
|
|
133
|
+
return disposePromise;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function disposeConnections(connections: McpClientConnection[]): Promise<void> {
|
|
139
|
+
const errors: unknown[] = [];
|
|
140
|
+
await closeConnections(connections, errors);
|
|
141
|
+
if (errors.length === 1) {
|
|
142
|
+
throw errors[0];
|
|
143
|
+
}
|
|
144
|
+
if (errors.length > 1) {
|
|
145
|
+
throw new AggregateError(errors, "Failed to close MCP connections.");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function closeConnections(
|
|
150
|
+
connections: McpClientConnection[],
|
|
151
|
+
errors: unknown[],
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
for (let index = connections.length - 1; index >= 0; index -= 1) {
|
|
154
|
+
const connection = connections[index];
|
|
155
|
+
if (connection === undefined) {
|
|
156
|
+
throw new Error(`Missing MCP connection at index ${index}.`);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await connection.close();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
errors.push(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parsePositiveIntegerEnv(name: string): number | undefined {
|
|
167
|
+
const value = process.env[name];
|
|
168
|
+
if (value === undefined || value.trim() === "") {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const parsed = Number(value);
|
|
173
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
174
|
+
throw new Error(`${name} must be a positive integer; received ${value}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return parsed;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function stdioClientFactory(
|
|
181
|
+
serverName: string,
|
|
182
|
+
serverConfig: McpServerConfig,
|
|
183
|
+
): Promise<McpClientConnection> {
|
|
184
|
+
const transport = new StdioClientTransport({
|
|
185
|
+
command: serverConfig.command,
|
|
186
|
+
args: serverConfig.args,
|
|
187
|
+
env: { ...getDefaultEnvironment(), ...serverConfig.env },
|
|
188
|
+
cwd: serverConfig.cwd,
|
|
189
|
+
stderr: "pipe",
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
let stderrTail = "";
|
|
193
|
+
transport.stderr?.on("data", (chunk: Buffer) => {
|
|
194
|
+
stderrTail = (stderrTail + chunk.toString("utf8")).slice(-STDERR_TAIL_MAX_CHARS);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const client = new Client({ name: "tinker", version: "0.1.0" });
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await client.connect(transport);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
await transport.close().catch(() => undefined);
|
|
203
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
204
|
+
const stderrSuffix =
|
|
205
|
+
stderrTail.trim() === "" ? "" : `\nServer stderr:\n${stderrTail.trim()}`;
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Failed to connect to MCP server "${serverName}" (${serverConfig.command}): ${message}${stderrSuffix}`,
|
|
208
|
+
{ cause: error },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
client,
|
|
214
|
+
close: () => client.close(),
|
|
215
|
+
};
|
|
216
|
+
}
|