@aaroncql/pim-agent 0.0.1

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,142 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { dirname, isAbsolute, parse, relative, resolve } from "node:path";
3
+ import ignore, { type Ignore } from "ignore";
4
+ import { FsErrors } from "./FsErrors";
5
+ import { Paths } from "./Paths";
6
+
7
+ type IgnoreMatcher = {
8
+ readonly baseDirectory: string;
9
+ readonly matcher: Ignore;
10
+ };
11
+
12
+ export class GitignoreFilter {
13
+ private static readonly alwaysIgnoredPatterns = [
14
+ ".git/",
15
+ "node_modules/",
16
+ "dist/",
17
+ "build/",
18
+ "out/",
19
+ "target/",
20
+ "coverage/",
21
+ ".next/",
22
+ ".cache/",
23
+ ".turbo/",
24
+ ".vercel/",
25
+ ".svelte-kit/",
26
+ ] as const;
27
+
28
+ private readonly matchers: readonly IgnoreMatcher[];
29
+
30
+ private constructor(matchers: readonly IgnoreMatcher[]) {
31
+ this.matchers = matchers;
32
+ }
33
+
34
+ public static async for(root: string): Promise<GitignoreFilter> {
35
+ const absoluteRoot = resolve(root);
36
+ const rootDirectory =
37
+ await GitignoreFilter.containingDirectory(absoluteRoot);
38
+ const directories =
39
+ await GitignoreFilter.gitignoreDirectories(rootDirectory);
40
+ const contents = await Promise.all(
41
+ directories.map((directory) => GitignoreFilter.readGitignore(directory))
42
+ );
43
+ const matchers: IgnoreMatcher[] = [
44
+ {
45
+ baseDirectory: rootDirectory,
46
+ matcher: ignore().add([...GitignoreFilter.alwaysIgnoredPatterns]),
47
+ },
48
+ ];
49
+
50
+ for (const [index, directory] of directories.entries()) {
51
+ const body = contents[index];
52
+
53
+ if (body !== undefined) {
54
+ matchers.push({
55
+ baseDirectory: directory,
56
+ matcher: ignore().add(body),
57
+ });
58
+ }
59
+ }
60
+
61
+ return new GitignoreFilter(matchers);
62
+ }
63
+
64
+ public ignores(absolutePath: string): boolean {
65
+ if (!isAbsolute(absolutePath)) {
66
+ throw new Error(`Expected absolute path: ${absolutePath}`);
67
+ }
68
+
69
+ for (const { baseDirectory, matcher } of this.matchers) {
70
+ const candidate = this.relativePath(baseDirectory, absolutePath);
71
+
72
+ if (candidate !== undefined && matcher.ignores(candidate)) {
73
+ return true;
74
+ }
75
+ }
76
+
77
+ return false;
78
+ }
79
+
80
+ private static async containingDirectory(path: string): Promise<string> {
81
+ const metadata = await stat(path);
82
+
83
+ return metadata.isDirectory() ? path : dirname(path);
84
+ }
85
+
86
+ private static async gitignoreDirectories(
87
+ root: string
88
+ ): Promise<readonly string[]> {
89
+ const directories: string[] = [];
90
+ const filesystemRoot = parse(root).root;
91
+ let current = root;
92
+
93
+ while (true) {
94
+ directories.push(current);
95
+
96
+ if (await Bun.file(resolve(current, ".git")).exists()) {
97
+ break;
98
+ }
99
+
100
+ if (current === filesystemRoot) {
101
+ break;
102
+ }
103
+
104
+ current = dirname(current);
105
+ }
106
+
107
+ return directories;
108
+ }
109
+
110
+ private static async readGitignore(
111
+ directory: string
112
+ ): Promise<string | undefined> {
113
+ const path = resolve(directory, ".gitignore");
114
+
115
+ try {
116
+ return await readFile(path, "utf8");
117
+ } catch (error) {
118
+ if (FsErrors.code(error) === "ENOENT") {
119
+ return undefined;
120
+ }
121
+
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ private relativePath(
127
+ baseDirectory: string,
128
+ absolutePath: string
129
+ ): string | undefined {
130
+ const candidate = relative(baseDirectory, absolutePath);
131
+
132
+ if (
133
+ candidate.length === 0 ||
134
+ candidate.startsWith("..") ||
135
+ isAbsolute(candidate)
136
+ ) {
137
+ return undefined;
138
+ }
139
+
140
+ return Paths.toForwardSlashes(candidate);
141
+ }
142
+ }
@@ -0,0 +1,23 @@
1
+ import { relative } from "node:path";
2
+ import { Paths } from "./Paths";
3
+
4
+ export class GlobExclusions {
5
+ public static compile(
6
+ exclude: readonly string[] | undefined
7
+ ): readonly Bun.Glob[] {
8
+ return (exclude ?? []).map((pattern) => new Bun.Glob(pattern));
9
+ }
10
+
11
+ public static ignores(
12
+ globs: readonly Bun.Glob[],
13
+ root: string,
14
+ path: string
15
+ ): boolean {
16
+ if (globs.length === 0) {
17
+ return false;
18
+ }
19
+
20
+ const candidate = Paths.toForwardSlashes(relative(root, path));
21
+ return globs.some((glob) => glob.match(candidate));
22
+ }
23
+ }
@@ -0,0 +1,33 @@
1
+ export class Levenshtein {
2
+ public static distance(left: string, right: string): number {
3
+ if (left === right) {
4
+ return 0;
5
+ }
6
+ if (left.length === 0 || right.length === 0) {
7
+ return Math.max(left.length, right.length);
8
+ }
9
+
10
+ let previous = Array.from(
11
+ { length: right.length + 1 },
12
+ (_, index) => index
13
+ );
14
+ let current = Array.from({ length: right.length + 1 }, () => 0);
15
+
16
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
17
+ current[0] = leftIndex;
18
+
19
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
20
+ const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
21
+ current[rightIndex] = Math.min(
22
+ previous[rightIndex]! + 1,
23
+ current[rightIndex - 1]! + 1,
24
+ previous[rightIndex - 1]! + cost
25
+ );
26
+ }
27
+
28
+ [previous, current] = [current, previous];
29
+ }
30
+
31
+ return previous[right.length] ?? 0;
32
+ }
33
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Lines } from "./Lines";
3
+
4
+ describe("Lines.continuationLine", () => {
5
+ test("resumes on the partial last line when the cut is mid-line", () => {
6
+ expect(Lines.continuationLine("a\nb\nc")).toBe(3);
7
+ });
8
+
9
+ test("resumes on the next line when the cut lands on a newline", () => {
10
+ expect(Lines.continuationLine("a\nb\n")).toBe(3);
11
+ });
12
+
13
+ test("treats a single unterminated line as line 1", () => {
14
+ expect(Lines.continuationLine("a")).toBe(1);
15
+ });
16
+
17
+ test("never returns below 1 for empty input", () => {
18
+ expect(Lines.continuationLine("")).toBe(1);
19
+ });
20
+
21
+ test("counts normalized newlines (CRLF and CR)", () => {
22
+ expect(Lines.continuationLine("a\r\nb\r\nc")).toBe(3);
23
+ expect(Lines.continuationLine("a\rb")).toBe(2);
24
+ });
25
+ });
@@ -0,0 +1,77 @@
1
+ export class Lines {
2
+ public static readonly utf8Bom = "\uFEFF";
3
+ public static readonly utf8BomBytes = new Uint8Array([0xef, 0xbb, 0xbf]);
4
+
5
+ public static normalize(content: string): string {
6
+ return Lines.stripUtf8Bom(content)
7
+ .replaceAll("\r\n", "\n")
8
+ .replaceAll("\r", "\n");
9
+ }
10
+
11
+ public static split(content: string): readonly string[] {
12
+ const normalized = Lines.normalize(content);
13
+
14
+ if (normalized.length === 0) {
15
+ return [];
16
+ }
17
+
18
+ const parts = normalized.split("\n");
19
+
20
+ if (parts.at(-1) === "") {
21
+ parts.pop();
22
+ }
23
+
24
+ return parts;
25
+ }
26
+
27
+ public static hasTrailingNewline(content: string): boolean {
28
+ return Lines.normalize(content).endsWith("\n");
29
+ }
30
+
31
+ /**
32
+ * Given a truncated head prefix of a larger file, the 1-based line to resume
33
+ * reading at so the (possibly mid-line) cut point is re-read in full. Matches
34
+ * how `read` numbers lines via `split`, so the hint lands on the right line.
35
+ */
36
+ public static continuationLine(head: string): number {
37
+ const { lines, hasTrailingNewline } = Lines.splitWithTrailingNewline(head);
38
+ return Math.max(1, lines.length + (hasTrailingNewline ? 1 : 0));
39
+ }
40
+
41
+ public static splitWithTrailingNewline(content: string): {
42
+ readonly lines: readonly string[];
43
+ readonly hasTrailingNewline: boolean;
44
+ } {
45
+ const normalized = Lines.normalize(content);
46
+
47
+ if (normalized.length === 0) {
48
+ return { lines: [], hasTrailingNewline: false };
49
+ }
50
+
51
+ const parts = normalized.split("\n");
52
+ const hasTrailingNewline = parts.at(-1) === "";
53
+
54
+ if (hasTrailingNewline) {
55
+ parts.pop();
56
+ }
57
+
58
+ return { lines: parts, hasTrailingNewline };
59
+ }
60
+
61
+ public static stripUtf8Bom(content: string): string {
62
+ return content.startsWith(Lines.utf8Bom) ? content.slice(1) : content;
63
+ }
64
+
65
+ public static hasUtf8Bom(bytes: Uint8Array): boolean {
66
+ return (
67
+ bytes[0] === Lines.utf8BomBytes[0] &&
68
+ bytes[1] === Lines.utf8BomBytes[1] &&
69
+ bytes[2] === Lines.utf8BomBytes[2]
70
+ );
71
+ }
72
+
73
+ public static async isBinary(file: Bun.BunFile): Promise<boolean> {
74
+ const bytes = new Uint8Array(await file.slice(0, 8192).arrayBuffer());
75
+ return bytes.includes(0);
76
+ }
77
+ }
@@ -0,0 +1,235 @@
1
+ import { expect, test } from "bun:test";
2
+ import { McpClient } from "./McpClient";
3
+
4
+ type MockFetch = (
5
+ input: Parameters<typeof fetch>[0],
6
+ init?: Parameters<typeof fetch>[1]
7
+ ) => ReturnType<typeof fetch>;
8
+
9
+ type CapturedRequest = {
10
+ readonly url: string;
11
+ readonly headers: Headers;
12
+ readonly body: Readonly<Record<string, unknown>>;
13
+ };
14
+
15
+ const captureRequest = async (
16
+ input: Parameters<typeof fetch>[0],
17
+ init: Parameters<typeof fetch>[1] | undefined
18
+ ): Promise<CapturedRequest> => {
19
+ if (input instanceof Request) {
20
+ const text = await input.clone().text();
21
+
22
+ return {
23
+ url: input.url,
24
+ headers: input.headers,
25
+ body: JSON.parse(text) as Readonly<Record<string, unknown>>,
26
+ };
27
+ }
28
+
29
+ return {
30
+ url: String(input),
31
+ headers: new Headers(init?.headers),
32
+ body: JSON.parse(String(init?.body)) as Readonly<Record<string, unknown>>,
33
+ };
34
+ };
35
+
36
+ const okToolCallResponse = (result: unknown): Response =>
37
+ Response.json({ jsonrpc: "2.0", id: 2, result });
38
+
39
+ test("performs the initialize → initialized → tools/call round trip", async () => {
40
+ const requests: CapturedRequest[] = [];
41
+ const fetcher: MockFetch = async (input, init) => {
42
+ const request = await captureRequest(input, init);
43
+ requests.push(request);
44
+
45
+ if (request.body["method"] === "initialize") {
46
+ return Response.json(
47
+ { jsonrpc: "2.0", id: 1, result: {} },
48
+ { headers: { "mcp-session-id": "session-json" } }
49
+ );
50
+ }
51
+
52
+ if (request.body["method"] === "notifications/initialized") {
53
+ return new Response(null, { status: 202 });
54
+ }
55
+
56
+ return okToolCallResponse({ ok: true });
57
+ };
58
+ const client = new McpClient({
59
+ endpoint: "https://mcp.test/mcp",
60
+ headers: { "x-api-key": "test-key" },
61
+ fetch: fetcher,
62
+ });
63
+
64
+ await expect(
65
+ client.callTool({ name: "demo_tool", arguments: { x: 1 } })
66
+ ).resolves.toEqual({ ok: true });
67
+
68
+ expect(requests.map((request) => request.url)).toEqual([
69
+ "https://mcp.test/mcp",
70
+ "https://mcp.test/mcp",
71
+ "https://mcp.test/mcp",
72
+ ]);
73
+ expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-json");
74
+ expect(requests[2]?.headers.get("mcp-session-id")).toBe("session-json");
75
+ expect(requests[0]?.headers.get("x-api-key")).toBe("test-key");
76
+ for (const request of requests) {
77
+ expect(request.headers.get("mcp-protocol-version")).toBe("2025-06-18");
78
+ }
79
+ expect(requests[0]?.body).toEqual({
80
+ jsonrpc: "2.0",
81
+ id: 1,
82
+ method: "initialize",
83
+ params: {
84
+ protocolVersion: "2025-06-18",
85
+ clientInfo: {
86
+ name: "pim-agent",
87
+ version: "0.0.0",
88
+ },
89
+ capabilities: {},
90
+ },
91
+ });
92
+ expect(requests[2]?.body).toEqual({
93
+ jsonrpc: "2.0",
94
+ id: 2,
95
+ method: "tools/call",
96
+ params: {
97
+ name: "demo_tool",
98
+ arguments: { x: 1 },
99
+ },
100
+ });
101
+ });
102
+
103
+ test("parses SSE responses", async () => {
104
+ const ssePayload = [
105
+ "event: message",
106
+ `data: ${JSON.stringify({
107
+ jsonrpc: "2.0",
108
+ id: 2,
109
+ result: { ok: true },
110
+ })}`,
111
+ "",
112
+ "",
113
+ ].join("\n");
114
+ const fetcher: MockFetch = async (input, init) => {
115
+ const request = await captureRequest(input, init);
116
+
117
+ if (request.body["method"] === "initialize") {
118
+ return Response.json(
119
+ { jsonrpc: "2.0", id: 1, result: {} },
120
+ { headers: { "mcp-session-id": "session-sse" } }
121
+ );
122
+ }
123
+
124
+ if (request.body["method"] === "notifications/initialized") {
125
+ return new Response(null, { status: 202 });
126
+ }
127
+
128
+ return new Response(ssePayload, {
129
+ headers: { "content-type": "text/event-stream" },
130
+ });
131
+ };
132
+ const client = new McpClient({
133
+ endpoint: "https://mcp.test/mcp",
134
+ fetch: fetcher,
135
+ });
136
+
137
+ await expect(
138
+ client.callTool({ name: "demo_tool", arguments: {} })
139
+ ).resolves.toEqual({ ok: true });
140
+ });
141
+
142
+ test("throws clean errors for HTTP failures", async () => {
143
+ const fetcher: MockFetch = async () =>
144
+ new Response("upstream unavailable and not useful beyond this excerpt", {
145
+ status: 503,
146
+ });
147
+ const client = new McpClient({
148
+ endpoint: "https://mcp.test/mcp",
149
+ fetch: fetcher,
150
+ });
151
+
152
+ await expect(
153
+ client.callTool({ name: "demo_tool", arguments: {} })
154
+ ).rejects.toThrow("MCP request failed with HTTP 503: upstream unavailable");
155
+ });
156
+
157
+ test("throws clean errors for JSON-RPC failures", async () => {
158
+ const fetcher: MockFetch = async (input, init) => {
159
+ const request = await captureRequest(input, init);
160
+
161
+ if (request.body["method"] === "initialize") {
162
+ return Response.json(
163
+ { jsonrpc: "2.0", id: 1, result: {} },
164
+ { headers: { "mcp-session-id": "session-error" } }
165
+ );
166
+ }
167
+
168
+ if (request.body["method"] === "notifications/initialized") {
169
+ return new Response(null, { status: 202 });
170
+ }
171
+
172
+ return Response.json({
173
+ jsonrpc: "2.0",
174
+ id: 2,
175
+ error: { code: -32602, message: "Invalid input." },
176
+ });
177
+ };
178
+ const client = new McpClient({
179
+ endpoint: "https://mcp.test/mcp",
180
+ fetch: fetcher,
181
+ });
182
+
183
+ await expect(
184
+ client.callTool({ name: "demo_tool", arguments: {} })
185
+ ).rejects.toThrow("MCP JSON-RPC error: Invalid input.");
186
+ });
187
+
188
+ test("sends cancellation notification on aborted calls", async () => {
189
+ const abortController = new AbortController();
190
+ const requests: CapturedRequest[] = [];
191
+ const fetcher: MockFetch = async (input, init) => {
192
+ const request = await captureRequest(input, init);
193
+ requests.push(request);
194
+
195
+ if (request.body["method"] === "initialize") {
196
+ return Response.json(
197
+ { jsonrpc: "2.0", id: 1, result: {} },
198
+ { headers: { "mcp-session-id": "session-abort" } }
199
+ );
200
+ }
201
+
202
+ if (request.body["method"] === "notifications/initialized") {
203
+ return new Response(null, { status: 202 });
204
+ }
205
+
206
+ if (request.body["method"] === "tools/call") {
207
+ abortController.abort();
208
+ throw new DOMException("Aborted", "AbortError");
209
+ }
210
+
211
+ return new Response(null, { status: 202 });
212
+ };
213
+ const client = new McpClient({
214
+ endpoint: "https://mcp.test/mcp",
215
+ fetch: fetcher,
216
+ });
217
+
218
+ await expect(
219
+ client.callTool({
220
+ name: "demo_tool",
221
+ arguments: {},
222
+ signal: abortController.signal,
223
+ })
224
+ ).rejects.toThrow("MCP request aborted.");
225
+
226
+ expect(requests.at(-1)?.body).toEqual({
227
+ jsonrpc: "2.0",
228
+ method: "notifications/cancelled",
229
+ params: {
230
+ requestId: 2,
231
+ reason: "Tool call aborted.",
232
+ },
233
+ });
234
+ expect(requests.at(-1)?.headers.get("mcp-session-id")).toBe("session-abort");
235
+ });