@ikuma.cloud/pix-mcp 0.0.7 → 0.0.9

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/src/client.ts CHANGED
@@ -9,14 +9,25 @@ import {
9
9
  CallToolResultSchema,
10
10
  GetPromptResultSchema,
11
11
  ListPromptsResultSchema,
12
+ ListResourcesResultSchema,
13
+ ListResourceTemplatesResultSchema,
12
14
  ListToolsResultSchema,
13
15
  PromptListChangedNotificationSchema,
16
+ ReadResourceResultSchema,
17
+ ResourceListChangedNotificationSchema,
14
18
  ToolSchema,
15
19
  ToolListChangedNotificationSchema,
16
20
  type Prompt,
21
+ type Resource,
22
+ type ResourceTemplate,
17
23
  type Tool,
18
24
  } from "@modelcontextprotocol/sdk/types.js";
19
25
  import type { ServerConfig } from "./config.ts";
26
+ import {
27
+ validateResource,
28
+ validateResourceTemplate,
29
+ validateResourceUri,
30
+ } from "./resources.ts";
20
31
  import { compileSchema, schemaValidator } from "./schema.ts";
21
32
 
22
33
  // SDK 1.30's property decoder incorrectly requires every property schema to be
@@ -39,27 +50,97 @@ const promptIdentifier = (value: string) =>
39
50
  Buffer.byteLength(value) <= 256 &&
40
51
  !/[\p{C}\p{Zl}\p{Zp}]/u.test(value);
41
52
 
53
+ // Leave bounded room for the JSON-RPC envelope around a maximum-size result.
54
+ const MAX_HTTP_RESPONSE_BYTES = 16 * 1024 * 1024 + 64 * 1024;
55
+
56
+ function responseLimitTransform(sse: boolean) {
57
+ let responseBytes = 0;
58
+ let eventBytes = 0;
59
+ let lineBytes = 0;
60
+ let previousCarriageReturn = false;
61
+ return new TransformStream<Uint8Array, Uint8Array>({
62
+ transform(chunk, controller) {
63
+ if (!sse) {
64
+ responseBytes += chunk.byteLength;
65
+ if (responseBytes > MAX_HTTP_RESPONSE_BYTES)
66
+ throw new Error("MCP HTTP response exceeded the size limit.");
67
+ } else {
68
+ for (const byte of chunk) {
69
+ eventBytes++;
70
+ if (eventBytes > MAX_HTTP_RESPONSE_BYTES)
71
+ throw new Error("MCP HTTP event exceeded the size limit.");
72
+ if (byte === 0x0d) {
73
+ if (lineBytes === 0) eventBytes = 0;
74
+ lineBytes = 0;
75
+ previousCarriageReturn = true;
76
+ } else if (byte === 0x0a) {
77
+ if (!previousCarriageReturn) {
78
+ if (lineBytes === 0) eventBytes = 0;
79
+ lineBytes = 0;
80
+ }
81
+ previousCarriageReturn = false;
82
+ } else {
83
+ previousCarriageReturn = false;
84
+ lineBytes++;
85
+ }
86
+ }
87
+ }
88
+ controller.enqueue(chunk);
89
+ },
90
+ });
91
+ }
92
+
93
+ function boundedHttpResponse(response: Response): Response {
94
+ if (!response.body) return response;
95
+ const sse =
96
+ mediaTypeEssence(response.headers.get("content-type")) ===
97
+ "text/event-stream";
98
+ const declaredLength = Number(response.headers.get("content-length"));
99
+ if (
100
+ !sse &&
101
+ Number.isFinite(declaredLength) &&
102
+ declaredLength > MAX_HTTP_RESPONSE_BYTES
103
+ ) {
104
+ void response.body.cancel();
105
+ throw new Error("MCP HTTP response exceeded the size limit.");
106
+ }
107
+ return new Response(response.body.pipeThrough(responseLimitTransform(sse)), {
108
+ status: response.status,
109
+ statusText: response.statusText,
110
+ headers: response.headers,
111
+ });
112
+ }
113
+
42
114
  export class Connection {
43
115
  readonly client: Client;
44
116
  readonly config: ServerConfig;
45
117
  status = "Not connected";
46
118
  toolStatus = "Not discovered";
47
119
  promptStatus = "Not discovered";
120
+ resourceStatus = "Not discovered";
48
121
  instructions = "";
49
122
  #transport: StdioClientTransport | StreamableHTTPClientTransport;
50
123
  #lifetime = new AbortController();
51
124
  #initializing: Promise<void> | undefined;
52
125
  #refreshing: Promise<void> | undefined;
53
126
  #promptRefreshing: Promise<void> | undefined;
127
+ #resourceRefreshing: Promise<void> | undefined;
54
128
  #closing: Promise<void> | undefined;
55
129
  #dirty = false;
56
130
  #promptDirty = false;
131
+ #resourceDirty = false;
57
132
  #stopped = false;
58
133
  #connected = false;
59
134
  #toolsReady = false;
60
135
  #promptsReady = false;
136
+ #resourcesReady = false;
137
+ #resourcesSupported = false;
61
138
  #changed: (tools: Tool[]) => void;
62
139
  #promptsChanged: (prompts: Prompt[]) => void;
140
+ #resourcesChanged: (
141
+ resources: Resource[],
142
+ templates: ResourceTemplate[],
143
+ ) => void;
63
144
  #promptNames = new Set<string>();
64
145
  #outputValidators = new Map<string, ReturnType<typeof compileSchema>>();
65
146
  #operation = new AsyncLocalStorage<AbortSignal>();
@@ -68,10 +149,15 @@ export class Connection {
68
149
  config: ServerConfig,
69
150
  changed: (tools: Tool[]) => void,
70
151
  promptsChanged: (prompts: Prompt[]) => void = () => {},
152
+ resourcesChanged: (
153
+ resources: Resource[],
154
+ templates: ResourceTemplate[],
155
+ ) => void = () => {},
71
156
  ) {
72
157
  this.config = config;
73
158
  this.#changed = changed;
74
159
  this.#promptsChanged = promptsChanged;
160
+ this.#resourcesChanged = resourcesChanged;
75
161
  this.client = new Client(
76
162
  { name: "pix-mcp", version: "0.0.0" },
77
163
  { capabilities: {}, jsonSchemaValidator: schemaValidator },
@@ -122,6 +208,17 @@ export class Connection {
122
208
  }
123
209
  },
124
210
  );
211
+ this.client.setNotificationHandler(
212
+ ResourceListChangedNotificationSchema,
213
+ async () => {
214
+ if (this.#stopped) return;
215
+ try {
216
+ await this.refreshResources();
217
+ } catch {
218
+ /* refresh already clears the stale catalog */
219
+ }
220
+ },
221
+ );
125
222
  }
126
223
 
127
224
  #disconnect() {
@@ -129,9 +226,12 @@ export class Connection {
129
226
  this.status = "Disconnected; reload Pi to reconnect";
130
227
  this.toolStatus = "Unavailable";
131
228
  this.promptStatus = "Unavailable";
229
+ this.resourceStatus = "Unavailable";
132
230
  this.#connected = false;
133
231
  this.#toolsReady = false;
134
232
  this.#promptsReady = false;
233
+ this.#resourcesReady = false;
234
+ this.#resourcesSupported = false;
135
235
  this.#lifetime.abort(
136
236
  new Error("MCP connection is unavailable; reload Pi to reconnect."),
137
237
  );
@@ -139,16 +239,20 @@ export class Connection {
139
239
  this.#promptNames.clear();
140
240
  this.#changed([]);
141
241
  this.#promptsChanged([]);
242
+ this.#resourcesChanged([], []);
142
243
  void this.client.close().catch(() => {});
143
244
  }
144
245
 
145
246
  #updateStatus() {
146
247
  if (!this.#connected || this.#stopped) return;
147
- if (this.#toolsReady && this.#promptsReady) this.status = "Connected";
148
- else if (this.#toolsReady)
149
- this.status = "Connected; prompt discovery failed";
150
- else if (this.#promptsReady)
151
- this.status = "Connected; tool discovery failed";
248
+ const failures = [
249
+ ...(!this.#toolsReady ? ["tool"] : []),
250
+ ...(!this.#promptsReady ? ["prompt"] : []),
251
+ ...(!this.#resourcesReady ? ["resource"] : []),
252
+ ];
253
+ if (failures.length === 0) this.status = "Connected";
254
+ else if (failures.length < 3)
255
+ this.status = `Connected; ${failures.join(" and ")} discovery failed`;
152
256
  else this.status = "Discovery failed; reload Pi to retry";
153
257
  }
154
258
 
@@ -211,7 +315,8 @@ export class Connection {
211
315
  ) as unknown as Promise<Response>;
212
316
  };
213
317
  // Teardown must still send DELETE after the connection lifetime is aborted.
214
- if (init?.method === "DELETE") return fetch(AbortSignal.timeout(2000));
318
+ if (init?.method === "DELETE")
319
+ return boundedHttpResponse(await fetch(AbortSignal.timeout(2000)));
215
320
  const signals = [
216
321
  this.#lifetime.signal,
217
322
  ...(init?.signal ? [init.signal] : []),
@@ -229,8 +334,10 @@ export class Connection {
229
334
  (("method" in message && "id" in message) ||
230
335
  message.method === "notifications/initialized");
231
336
  const operation = scoped ? this.#operation.getStore() : undefined;
232
- return fetch(
233
- AbortSignal.any([...signals, operation ?? AbortSignal.timeout(2000)]),
337
+ return boundedHttpResponse(
338
+ await fetch(
339
+ AbortSignal.any([...signals, operation ?? AbortSignal.timeout(2000)]),
340
+ ),
234
341
  );
235
342
  }
236
343
  // GET is optional (405 is valid), but an established notification stream
@@ -254,7 +361,7 @@ export class Connection {
254
361
  await response.body?.cancel();
255
362
  throw new Error("MCP notification stream unavailable.");
256
363
  }
257
- const stream = new TransformStream<Uint8Array, Uint8Array>();
364
+ const stream = responseLimitTransform(true);
258
365
  void response.body.pipeTo(stream.writable).then(
259
366
  () => this.#disconnect(),
260
367
  () => this.#disconnect(),
@@ -312,8 +419,10 @@ export class Connection {
312
419
  "Connection failed; check configuration/authentication and reload Pi";
313
420
  this.toolStatus = "Unavailable";
314
421
  this.promptStatus = "Unavailable";
422
+ this.resourceStatus = "Unavailable";
315
423
  this.#changed([]);
316
424
  this.#promptsChanged([]);
425
+ this.#resourcesChanged([], []);
317
426
  }
318
427
  await this.client.close().catch(() => {});
319
428
  throw new Error(`MCP server ${this.config.name}: connection failed.`);
@@ -321,6 +430,7 @@ export class Connection {
321
430
  const results = await Promise.allSettled([
322
431
  this.refresh(),
323
432
  this.refreshPrompts(),
433
+ this.refreshResources(),
324
434
  ]);
325
435
  if (results.some((result) => result.status === "rejected"))
326
436
  throw new Error(`MCP server ${this.config.name}: discovery failed.`);
@@ -551,6 +661,217 @@ export class Connection {
551
661
  }
552
662
  }
553
663
 
664
+ refreshResources(): Promise<void> {
665
+ this.#resourceDirty = true;
666
+ // Do not let picker selections remain current while either half of the
667
+ // atomic resource/template snapshot is being refreshed.
668
+ if (this.#resourcesReady) {
669
+ this.#resourcesReady = false;
670
+ this.resourceStatus = "Loading";
671
+ this.#resourcesChanged([], []);
672
+ }
673
+ this.#resourceRefreshing ??= this.#drainResourceRefreshes();
674
+ return this.#resourceRefreshing;
675
+ }
676
+
677
+ async #drainResourceRefreshes(): Promise<void> {
678
+ let failed = false;
679
+ let failure: unknown;
680
+ try {
681
+ await this.#refreshResources();
682
+ } catch (error) {
683
+ failed = true;
684
+ failure = error;
685
+ }
686
+ this.#resourceRefreshing = undefined;
687
+ if (this.#resourceDirty && !this.#stopped) return this.refreshResources();
688
+ if (failed) throw failure;
689
+ }
690
+
691
+ async #refreshResources(): Promise<void> {
692
+ try {
693
+ while (this.#resourceDirty && !this.#stopped) {
694
+ this.#resourceDirty = false;
695
+ const resources: Resource[] = [];
696
+ const templates: ResourceTemplate[] = [];
697
+ const resourceCursors = new Set<string>();
698
+ const templateCursors = new Set<string>();
699
+ const uris = new Set<string>();
700
+ const uriTemplates = new Set<string>();
701
+ const supportsResources = Boolean(
702
+ this.client.getServerCapabilities()?.resources,
703
+ );
704
+ let bytes = 0;
705
+ await this.#deadline(
706
+ this.config.catalogTimeoutMs,
707
+ undefined,
708
+ async (signal) => {
709
+ if (supportsResources) {
710
+ let cursor: string | undefined;
711
+ do {
712
+ const page = await this.#deadline(
713
+ this.config.catalogTimeoutMs,
714
+ signal,
715
+ (requestSignal) =>
716
+ this.client.request(
717
+ {
718
+ method: "resources/list",
719
+ params: cursor === undefined ? {} : { cursor },
720
+ },
721
+ ListResourcesResultSchema,
722
+ {
723
+ signal: requestSignal,
724
+ timeout: this.config.catalogTimeoutMs,
725
+ },
726
+ ),
727
+ );
728
+ bytes += Buffer.byteLength(JSON.stringify(page));
729
+ if (
730
+ bytes > 2 * 1024 * 1024 ||
731
+ resources.length + templates.length + page.resources.length >
732
+ 1000
733
+ )
734
+ throw new Error("Catalog limit");
735
+ for (const resource of page.resources) {
736
+ validateResource(resource);
737
+ if (uris.has(resource.uri))
738
+ throw new Error("Duplicate resource URI");
739
+ uris.add(resource.uri);
740
+ const catalogResource = { ...resource };
741
+ delete catalogResource._meta;
742
+ delete catalogResource.icons;
743
+ resources.push(catalogResource);
744
+ }
745
+ cursor = page.nextCursor;
746
+ if (cursor !== undefined) {
747
+ if (
748
+ resourceCursors.has(cursor) ||
749
+ resourceCursors.size >= 100
750
+ )
751
+ throw new Error("Invalid pagination");
752
+ resourceCursors.add(cursor);
753
+ }
754
+ } while (cursor !== undefined);
755
+
756
+ cursor = undefined;
757
+ do {
758
+ const page = await this.#deadline(
759
+ this.config.catalogTimeoutMs,
760
+ signal,
761
+ (requestSignal) =>
762
+ this.client.request(
763
+ {
764
+ method: "resources/templates/list",
765
+ params: cursor === undefined ? {} : { cursor },
766
+ },
767
+ ListResourceTemplatesResultSchema,
768
+ {
769
+ signal: requestSignal,
770
+ timeout: this.config.catalogTimeoutMs,
771
+ },
772
+ ),
773
+ );
774
+ bytes += Buffer.byteLength(JSON.stringify(page));
775
+ if (
776
+ bytes > 2 * 1024 * 1024 ||
777
+ resources.length +
778
+ templates.length +
779
+ page.resourceTemplates.length >
780
+ 1000
781
+ )
782
+ throw new Error("Catalog limit");
783
+ for (const template of page.resourceTemplates) {
784
+ validateResourceTemplate(template);
785
+ if (uriTemplates.has(template.uriTemplate))
786
+ throw new Error("Duplicate resource URI template");
787
+ uriTemplates.add(template.uriTemplate);
788
+ const catalogTemplate = { ...template };
789
+ delete catalogTemplate._meta;
790
+ delete catalogTemplate.icons;
791
+ templates.push(catalogTemplate);
792
+ }
793
+ cursor = page.nextCursor;
794
+ if (cursor !== undefined) {
795
+ if (
796
+ templateCursors.has(cursor) ||
797
+ templateCursors.size >= 100
798
+ )
799
+ throw new Error("Invalid pagination");
800
+ templateCursors.add(cursor);
801
+ }
802
+ } while (cursor !== undefined);
803
+ }
804
+ signal.throwIfAborted();
805
+ // One notification covers both list endpoints. Never publish a
806
+ // cross-revision pair when it arrives between those requests.
807
+ if (!this.#stopped && !this.#resourceDirty) {
808
+ this.#resourcesReady = true;
809
+ this.#resourcesSupported = supportsResources;
810
+ this.resourceStatus = supportsResources
811
+ ? "Available"
812
+ : "Not supported";
813
+ this.#resourcesChanged(resources, templates);
814
+ this.#updateStatus();
815
+ }
816
+ },
817
+ );
818
+ }
819
+ } catch {
820
+ if (!this.#stopped) {
821
+ this.#resourcesReady = false;
822
+ this.#resourcesSupported = false;
823
+ this.resourceStatus = "Discovery failed; reload Pi to retry";
824
+ this.#resourcesChanged([], []);
825
+ this.#updateStatus();
826
+ }
827
+ throw new Error(
828
+ `MCP server ${this.config.name}: resource discovery failed.`,
829
+ );
830
+ }
831
+ }
832
+
833
+ async readResource(uri: string, signal?: AbortSignal) {
834
+ await this.start().catch(() => {});
835
+ const combined = signal
836
+ ? AbortSignal.any([signal, this.#lifetime.signal])
837
+ : this.#lifetime.signal;
838
+ combined.throwIfAborted();
839
+ if (!this.#resourcesReady)
840
+ throw new Error(
841
+ "MCP resource catalog is unavailable; reload Pi to retry.",
842
+ );
843
+ if (!this.#resourcesSupported)
844
+ throw new Error("MCP server does not support resources.");
845
+ validateResourceUri(uri);
846
+ try {
847
+ const result = await this.#deadline(
848
+ this.config.timeout,
849
+ combined,
850
+ (requestSignal) =>
851
+ this.client.request(
852
+ { method: "resources/read", params: { uri } },
853
+ ReadResourceResultSchema,
854
+ {
855
+ signal: requestSignal,
856
+ timeout: this.config.timeout,
857
+ resetTimeoutOnProgress: false,
858
+ },
859
+ ),
860
+ );
861
+ if (
862
+ result.contents.length > 100 ||
863
+ Buffer.byteLength(JSON.stringify(result)) > 16 * 1024 * 1024
864
+ )
865
+ throw new Error("Resource result limit");
866
+ return result;
867
+ } catch {
868
+ combined.throwIfAborted();
869
+ throw new Error(
870
+ `MCP server ${this.config.name}: resource request failed or timed out.`,
871
+ );
872
+ }
873
+ }
874
+
554
875
  async getPrompt(
555
876
  name: string,
556
877
  args: Record<string, string> | undefined,
@@ -656,12 +977,16 @@ export class Connection {
656
977
  this.status = "Closed";
657
978
  this.toolStatus = "Closed";
658
979
  this.promptStatus = "Closed";
980
+ this.resourceStatus = "Closed";
659
981
  this.#toolsReady = false;
660
982
  this.#promptsReady = false;
983
+ this.#resourcesReady = false;
984
+ this.#resourcesSupported = false;
661
985
  this.#outputValidators.clear();
662
986
  this.#promptNames.clear();
663
987
  this.#changed([]);
664
988
  this.#promptsChanged([]);
989
+ this.#resourcesChanged([], []);
665
990
  if (
666
991
  this.#transport instanceof StreamableHTTPClientTransport &&
667
992
  this.#transport.sessionId
@@ -672,5 +997,6 @@ export class Connection {
672
997
  await this.#initializing?.catch(() => {});
673
998
  await this.#refreshing?.catch(() => {});
674
999
  await this.#promptRefreshing?.catch(() => {});
1000
+ await this.#resourceRefreshing?.catch(() => {});
675
1001
  }
676
1002
  }
package/src/command.ts ADDED
@@ -0,0 +1,69 @@
1
+ export interface CommandToken {
2
+ value: string;
3
+ separator?: number;
4
+ }
5
+
6
+ export function quoteCommandArgument(value: string): string {
7
+ return /^[A-Za-z0-9_.:/-]+$/.test(value)
8
+ ? value
9
+ : `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
10
+ }
11
+
12
+ export function renderCommandToken(token: CommandToken): string {
13
+ if (token.separator === undefined) return quoteCommandArgument(token.value);
14
+ const name = quoteCommandArgument(token.value.slice(0, token.separator));
15
+ const value = token.value.slice(token.separator + 1);
16
+ return `${name}=${value === "" ? "" : quoteCommandArgument(value)}`;
17
+ }
18
+
19
+ export function tokenizeCommand(
20
+ input: string,
21
+ subject = "command",
22
+ ): CommandToken[] {
23
+ const tokens: CommandToken[] = [];
24
+ let token = "";
25
+ let separator: number | undefined;
26
+ let quote: '"' | "'" | undefined;
27
+ let escaped = false;
28
+ let started = false;
29
+ for (const character of input) {
30
+ if (escaped) {
31
+ token += character;
32
+ escaped = false;
33
+ started = true;
34
+ } else if (character === "\\" && quote !== "'") {
35
+ escaped = true;
36
+ started = true;
37
+ } else if (quote) {
38
+ if (character === quote) quote = undefined;
39
+ else token += character;
40
+ started = true;
41
+ } else if (character === '"' || character === "'") {
42
+ quote = character;
43
+ started = true;
44
+ } else if (/\s/u.test(character)) {
45
+ if (started) {
46
+ tokens.push({
47
+ value: token,
48
+ ...(separator !== undefined ? { separator } : {}),
49
+ });
50
+ token = "";
51
+ separator = undefined;
52
+ started = false;
53
+ }
54
+ } else {
55
+ if (character === "=" && separator === undefined)
56
+ separator = token.length;
57
+ token += character;
58
+ started = true;
59
+ }
60
+ }
61
+ if (quote) throw new Error(`Unterminated quote in ${subject} arguments.`);
62
+ if (escaped) throw new Error(`Trailing escape in ${subject} arguments.`);
63
+ if (started)
64
+ tokens.push({
65
+ value: token,
66
+ ...(separator !== undefined ? { separator } : {}),
67
+ });
68
+ return tokens;
69
+ }
package/src/config.ts CHANGED
@@ -130,7 +130,7 @@ function parseServer(
130
130
  if (Object.hasOwn(server, "timeoutMs"))
131
131
  invalid(
132
132
  "timeoutMs",
133
- "Removed; use timeout for tool calls, startupTimeoutMs for initialization, and catalogTimeoutMs for discovery (milliseconds).",
133
+ "Removed; use timeout for tool calls, prompt retrievals, and resource reads; startupTimeoutMs for initialization; and catalogTimeoutMs for discovery (milliseconds).",
134
134
  );
135
135
  if (Object.hasOwn(server, "approve"))
136
136
  invalid(