opencode-toolbox 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -6
  2. package/dist/index.js +705 -54
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # opencode-toolbox (Tool Search Tool)
2
2
 
3
+ [![npm version](https://badge.fury.io/js/opencode-toolbox.svg)](https://www.npmjs.com/package/opencode-toolbox)
4
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-toolbox)](https://www.npmjs.com/package/opencode-toolbox)
5
+ [![license](https://img.shields.io/npm/l/opencode-toolbox)](LICENSE)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](https://www.typescriptlang.org/)
7
+ [![Build Status](https://github.com/assagman/opencode-toolbox/actions/workflows/ci.yml/badge.svg)](https://github.com/assagman/opencode-toolbox/actions/workflows/ci.yml)
8
+ [![codecov](https://codecov.io/gh/assagman/opencode-toolbox/branch/main/graph/badge.svg)](https://codecov.io/gh/assagman/opencode-toolbox)
9
+
3
10
  An OpenCode plugin that implements a **tool search tool** pattern, allowing users to keep only a tiny set of tools in LLM context while accessing a larger MCP catalog on-demand.
4
11
 
5
12
  ## Motivation
@@ -20,10 +27,19 @@ Add `opencode-toolbox` to your `~/.config/opencode/opencode.jsonc`:
20
27
 
21
28
  ```jsonc
22
29
  {
23
- "plugin": ["opencode-toolbox"]
30
+ "plugin": ["opencode-toolbox@x.y.z"]
24
31
  }
25
32
  ```
26
33
 
34
+ > **⚡ Performance Tip:** Always pin to a specific version for instant startup.
35
+ >
36
+ > | Configuration | Startup Time | Why |
37
+ > |---------------|--------------|-----|
38
+ > | ✅ `opencode-toolbox@x.y.z` | ~0ms | Cached version matches, skips npm check |
39
+ > | ❌ `opencode-toolbox` | ~1000ms | Resolves "latest" via npm on every startup |
40
+ >
41
+ > OpenCode caches plugins by exact version. When you omit the version, it defaults to `"latest"` which doesn't match the cached resolved version, triggering a fresh `bun add` on every startup. Check [npm](https://www.npmjs.com/package/opencode-toolbox) for the latest version.
42
+
27
43
  ### 2. Configure Toolbox
28
44
 
29
45
  Create `~/.config/opencode/toolbox.jsonc`:
@@ -105,16 +121,16 @@ Both search tools return tool schemas so the LLM knows exact parameters:
105
121
  }
106
122
  }
107
123
  ],
108
- "usage": "Use toolbox_execute({ name: '<tool_name>', arguments: '<json>' }) to run a discovered tool"
124
+ "usage": "Use toolbox_execute({ toolId: '<toolId>', arguments: '<json>' }) to run a discovered tool"
109
125
  }
110
126
  ```
111
127
 
112
128
  ### toolbox_execute
113
129
 
114
- Execute a discovered tool with JSON-encoded arguments:
130
+ Execute a discovered tool with JSON-encoded arguments. The `toolId` format is `{serverName}_{toolName}`:
115
131
 
116
132
  ```
117
- toolbox_execute({ name: "time_get_current_time", arguments: '{"timezone": "Asia/Tokyo"}' })
133
+ toolbox_execute({ toolId: "time_get_current_time", arguments: '{"timezone": "Asia/Tokyo"}' })
118
134
  ```
119
135
 
120
136
  ## Example Flow
@@ -128,7 +144,7 @@ LLM: I need to find a time-related tool.
128
144
  Toolbox: Returns time_get_current_time with its schema
129
145
 
130
146
  LLM: Now I know the parameters. Let me call it.
131
- toolbox_execute({ name: "time_get_current_time", arguments: '{"timezone":"Asia/Tokyo"}' })
147
+ toolbox_execute({ toolId: "time_get_current_time", arguments: '{"timezone":"Asia/Tokyo"}' })
132
148
 
133
149
  Toolbox: { "datetime": "2026-01-07T02:15:00+09:00", "timezone": "Asia/Tokyo" }
134
150
 
@@ -368,7 +384,7 @@ This shows:
368
384
  ### Execute fails
369
385
 
370
386
  1. Run `toolbox_status({})` to check server health
371
- 2. Verify tool name format: `serverName_toolName`
387
+ 2. Verify `toolId` format: `{serverName}_{toolName}`
372
388
  3. Check `arguments` is valid JSON
373
389
  4. Ensure underlying MCP server is running and connected
374
390
  5. Check logs for detailed error messages
package/dist/index.js CHANGED
@@ -32809,7 +32809,7 @@ var LocalServerConfigSchema = exports_external2.object({
32809
32809
  });
32810
32810
  var RemoteServerConfigSchema = exports_external2.object({
32811
32811
  type: exports_external2.literal("remote"),
32812
- url: exports_external2.string().url().describe("SSE endpoint URL"),
32812
+ url: exports_external2.string().url().describe("Remote MCP endpoint URL"),
32813
32813
  headers: exports_external2.record(exports_external2.string(), exports_external2.string()).optional().describe("HTTP headers for authentication")
32814
32814
  });
32815
32815
  var ServerConfigSchema = exports_external2.discriminatedUnion("type", [
@@ -33953,6 +33953,7 @@ var InitializedNotificationSchema = NotificationSchema.extend({
33953
33953
  method: literal2("notifications/initialized"),
33954
33954
  params: NotificationsParamsSchema.optional()
33955
33955
  });
33956
+ var isInitializedNotification = (value) => InitializedNotificationSchema.safeParse(value).success;
33956
33957
  var PingRequestSchema = RequestSchema.extend({
33957
33958
  method: literal2("ping"),
33958
33959
  params: BaseRequestParamsSchema.optional()
@@ -37584,6 +37585,395 @@ class SSEClientTransport {
37584
37585
  }
37585
37586
  }
37586
37587
 
37588
+ // node_modules/eventsource-parser/dist/stream.js
37589
+ class EventSourceParserStream extends TransformStream {
37590
+ constructor({ onError, onRetry, onComment } = {}) {
37591
+ let parser;
37592
+ super({
37593
+ start(controller) {
37594
+ parser = createParser({
37595
+ onEvent: (event) => {
37596
+ controller.enqueue(event);
37597
+ },
37598
+ onError(error92) {
37599
+ onError === "terminate" ? controller.error(error92) : typeof onError == "function" && onError(error92);
37600
+ },
37601
+ onRetry,
37602
+ onComment
37603
+ });
37604
+ },
37605
+ transform(chunk) {
37606
+ parser.feed(chunk);
37607
+ }
37608
+ });
37609
+ }
37610
+ }
37611
+
37612
+ // node_modules/@modelcontextprotocol/sdk/dist/esm/client/streamableHttp.js
37613
+ var DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS = {
37614
+ initialReconnectionDelay: 1000,
37615
+ maxReconnectionDelay: 30000,
37616
+ reconnectionDelayGrowFactor: 1.5,
37617
+ maxRetries: 2
37618
+ };
37619
+
37620
+ class StreamableHTTPError extends Error {
37621
+ constructor(code, message) {
37622
+ super(`Streamable HTTP error: ${message}`);
37623
+ this.code = code;
37624
+ }
37625
+ }
37626
+
37627
+ class StreamableHTTPClientTransport {
37628
+ constructor(url3, opts) {
37629
+ this._hasCompletedAuthFlow = false;
37630
+ this._url = url3;
37631
+ this._resourceMetadataUrl = undefined;
37632
+ this._scope = undefined;
37633
+ this._requestInit = opts?.requestInit;
37634
+ this._authProvider = opts?.authProvider;
37635
+ this._fetch = opts?.fetch;
37636
+ this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
37637
+ this._sessionId = opts?.sessionId;
37638
+ this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
37639
+ }
37640
+ async _authThenStart() {
37641
+ if (!this._authProvider) {
37642
+ throw new UnauthorizedError("No auth provider");
37643
+ }
37644
+ let result;
37645
+ try {
37646
+ result = await auth(this._authProvider, {
37647
+ serverUrl: this._url,
37648
+ resourceMetadataUrl: this._resourceMetadataUrl,
37649
+ scope: this._scope,
37650
+ fetchFn: this._fetchWithInit
37651
+ });
37652
+ } catch (error92) {
37653
+ this.onerror?.(error92);
37654
+ throw error92;
37655
+ }
37656
+ if (result !== "AUTHORIZED") {
37657
+ throw new UnauthorizedError;
37658
+ }
37659
+ return await this._startOrAuthSse({ resumptionToken: undefined });
37660
+ }
37661
+ async _commonHeaders() {
37662
+ const headers = {};
37663
+ if (this._authProvider) {
37664
+ const tokens = await this._authProvider.tokens();
37665
+ if (tokens) {
37666
+ headers["Authorization"] = `Bearer ${tokens.access_token}`;
37667
+ }
37668
+ }
37669
+ if (this._sessionId) {
37670
+ headers["mcp-session-id"] = this._sessionId;
37671
+ }
37672
+ if (this._protocolVersion) {
37673
+ headers["mcp-protocol-version"] = this._protocolVersion;
37674
+ }
37675
+ const extraHeaders = normalizeHeaders(this._requestInit?.headers);
37676
+ return new Headers({
37677
+ ...headers,
37678
+ ...extraHeaders
37679
+ });
37680
+ }
37681
+ async _startOrAuthSse(options) {
37682
+ const { resumptionToken } = options;
37683
+ try {
37684
+ const headers = await this._commonHeaders();
37685
+ headers.set("Accept", "text/event-stream");
37686
+ if (resumptionToken) {
37687
+ headers.set("last-event-id", resumptionToken);
37688
+ }
37689
+ const response = await (this._fetch ?? fetch)(this._url, {
37690
+ method: "GET",
37691
+ headers,
37692
+ signal: this._abortController?.signal
37693
+ });
37694
+ if (!response.ok) {
37695
+ await response.body?.cancel();
37696
+ if (response.status === 401 && this._authProvider) {
37697
+ return await this._authThenStart();
37698
+ }
37699
+ if (response.status === 405) {
37700
+ return;
37701
+ }
37702
+ throw new StreamableHTTPError(response.status, `Failed to open SSE stream: ${response.statusText}`);
37703
+ }
37704
+ this._handleSseStream(response.body, options, true);
37705
+ } catch (error92) {
37706
+ this.onerror?.(error92);
37707
+ throw error92;
37708
+ }
37709
+ }
37710
+ _getNextReconnectionDelay(attempt) {
37711
+ if (this._serverRetryMs !== undefined) {
37712
+ return this._serverRetryMs;
37713
+ }
37714
+ const initialDelay = this._reconnectionOptions.initialReconnectionDelay;
37715
+ const growFactor = this._reconnectionOptions.reconnectionDelayGrowFactor;
37716
+ const maxDelay = this._reconnectionOptions.maxReconnectionDelay;
37717
+ return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay);
37718
+ }
37719
+ _scheduleReconnection(options, attemptCount = 0) {
37720
+ const maxRetries = this._reconnectionOptions.maxRetries;
37721
+ if (attemptCount >= maxRetries) {
37722
+ this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`));
37723
+ return;
37724
+ }
37725
+ const delay = this._getNextReconnectionDelay(attemptCount);
37726
+ this._reconnectionTimeout = setTimeout(() => {
37727
+ this._startOrAuthSse(options).catch((error92) => {
37728
+ this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error92 instanceof Error ? error92.message : String(error92)}`));
37729
+ this._scheduleReconnection(options, attemptCount + 1);
37730
+ });
37731
+ }, delay);
37732
+ }
37733
+ _handleSseStream(stream, options, isReconnectable) {
37734
+ if (!stream) {
37735
+ return;
37736
+ }
37737
+ const { onresumptiontoken, replayMessageId } = options;
37738
+ let lastEventId;
37739
+ let hasPrimingEvent = false;
37740
+ let receivedResponse = false;
37741
+ const processStream = async () => {
37742
+ try {
37743
+ const reader = stream.pipeThrough(new TextDecoderStream).pipeThrough(new EventSourceParserStream({
37744
+ onRetry: (retryMs) => {
37745
+ this._serverRetryMs = retryMs;
37746
+ }
37747
+ })).getReader();
37748
+ while (true) {
37749
+ const { value: event, done } = await reader.read();
37750
+ if (done) {
37751
+ break;
37752
+ }
37753
+ if (event.id) {
37754
+ lastEventId = event.id;
37755
+ hasPrimingEvent = true;
37756
+ onresumptiontoken?.(event.id);
37757
+ }
37758
+ if (!event.data) {
37759
+ continue;
37760
+ }
37761
+ if (!event.event || event.event === "message") {
37762
+ try {
37763
+ const message = JSONRPCMessageSchema.parse(JSON.parse(event.data));
37764
+ if (isJSONRPCResultResponse(message)) {
37765
+ receivedResponse = true;
37766
+ if (replayMessageId !== undefined) {
37767
+ message.id = replayMessageId;
37768
+ }
37769
+ }
37770
+ this.onmessage?.(message);
37771
+ } catch (error92) {
37772
+ this.onerror?.(error92);
37773
+ }
37774
+ }
37775
+ }
37776
+ const canResume = isReconnectable || hasPrimingEvent;
37777
+ const needsReconnect = canResume && !receivedResponse;
37778
+ if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
37779
+ this._scheduleReconnection({
37780
+ resumptionToken: lastEventId,
37781
+ onresumptiontoken,
37782
+ replayMessageId
37783
+ }, 0);
37784
+ }
37785
+ } catch (error92) {
37786
+ this.onerror?.(new Error(`SSE stream disconnected: ${error92}`));
37787
+ const canResume = isReconnectable || hasPrimingEvent;
37788
+ const needsReconnect = canResume && !receivedResponse;
37789
+ if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
37790
+ try {
37791
+ this._scheduleReconnection({
37792
+ resumptionToken: lastEventId,
37793
+ onresumptiontoken,
37794
+ replayMessageId
37795
+ }, 0);
37796
+ } catch (error93) {
37797
+ this.onerror?.(new Error(`Failed to reconnect: ${error93 instanceof Error ? error93.message : String(error93)}`));
37798
+ }
37799
+ }
37800
+ }
37801
+ };
37802
+ processStream();
37803
+ }
37804
+ async start() {
37805
+ if (this._abortController) {
37806
+ throw new Error("StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.");
37807
+ }
37808
+ this._abortController = new AbortController;
37809
+ }
37810
+ async finishAuth(authorizationCode) {
37811
+ if (!this._authProvider) {
37812
+ throw new UnauthorizedError("No auth provider");
37813
+ }
37814
+ const result = await auth(this._authProvider, {
37815
+ serverUrl: this._url,
37816
+ authorizationCode,
37817
+ resourceMetadataUrl: this._resourceMetadataUrl,
37818
+ scope: this._scope,
37819
+ fetchFn: this._fetchWithInit
37820
+ });
37821
+ if (result !== "AUTHORIZED") {
37822
+ throw new UnauthorizedError("Failed to authorize");
37823
+ }
37824
+ }
37825
+ async close() {
37826
+ if (this._reconnectionTimeout) {
37827
+ clearTimeout(this._reconnectionTimeout);
37828
+ this._reconnectionTimeout = undefined;
37829
+ }
37830
+ this._abortController?.abort();
37831
+ this.onclose?.();
37832
+ }
37833
+ async send(message, options) {
37834
+ try {
37835
+ const { resumptionToken, onresumptiontoken } = options || {};
37836
+ if (resumptionToken) {
37837
+ this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch((err) => this.onerror?.(err));
37838
+ return;
37839
+ }
37840
+ const headers = await this._commonHeaders();
37841
+ headers.set("content-type", "application/json");
37842
+ headers.set("accept", "application/json, text/event-stream");
37843
+ const init = {
37844
+ ...this._requestInit,
37845
+ method: "POST",
37846
+ headers,
37847
+ body: JSON.stringify(message),
37848
+ signal: this._abortController?.signal
37849
+ };
37850
+ const response = await (this._fetch ?? fetch)(this._url, init);
37851
+ const sessionId = response.headers.get("mcp-session-id");
37852
+ if (sessionId) {
37853
+ this._sessionId = sessionId;
37854
+ }
37855
+ if (!response.ok) {
37856
+ const text = await response.text().catch(() => null);
37857
+ if (response.status === 401 && this._authProvider) {
37858
+ if (this._hasCompletedAuthFlow) {
37859
+ throw new StreamableHTTPError(401, "Server returned 401 after successful authentication");
37860
+ }
37861
+ const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
37862
+ this._resourceMetadataUrl = resourceMetadataUrl;
37863
+ this._scope = scope;
37864
+ const result = await auth(this._authProvider, {
37865
+ serverUrl: this._url,
37866
+ resourceMetadataUrl: this._resourceMetadataUrl,
37867
+ scope: this._scope,
37868
+ fetchFn: this._fetchWithInit
37869
+ });
37870
+ if (result !== "AUTHORIZED") {
37871
+ throw new UnauthorizedError;
37872
+ }
37873
+ this._hasCompletedAuthFlow = true;
37874
+ return this.send(message);
37875
+ }
37876
+ if (response.status === 403 && this._authProvider) {
37877
+ const { resourceMetadataUrl, scope, error: error92 } = extractWWWAuthenticateParams(response);
37878
+ if (error92 === "insufficient_scope") {
37879
+ const wwwAuthHeader = response.headers.get("WWW-Authenticate");
37880
+ if (this._lastUpscopingHeader === wwwAuthHeader) {
37881
+ throw new StreamableHTTPError(403, "Server returned 403 after trying upscoping");
37882
+ }
37883
+ if (scope) {
37884
+ this._scope = scope;
37885
+ }
37886
+ if (resourceMetadataUrl) {
37887
+ this._resourceMetadataUrl = resourceMetadataUrl;
37888
+ }
37889
+ this._lastUpscopingHeader = wwwAuthHeader ?? undefined;
37890
+ const result = await auth(this._authProvider, {
37891
+ serverUrl: this._url,
37892
+ resourceMetadataUrl: this._resourceMetadataUrl,
37893
+ scope: this._scope,
37894
+ fetchFn: this._fetch
37895
+ });
37896
+ if (result !== "AUTHORIZED") {
37897
+ throw new UnauthorizedError;
37898
+ }
37899
+ return this.send(message);
37900
+ }
37901
+ }
37902
+ throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);
37903
+ }
37904
+ this._hasCompletedAuthFlow = false;
37905
+ this._lastUpscopingHeader = undefined;
37906
+ if (response.status === 202) {
37907
+ await response.body?.cancel();
37908
+ if (isInitializedNotification(message)) {
37909
+ this._startOrAuthSse({ resumptionToken: undefined }).catch((err) => this.onerror?.(err));
37910
+ }
37911
+ return;
37912
+ }
37913
+ const messages = Array.isArray(message) ? message : [message];
37914
+ const hasRequests = messages.filter((msg) => ("method" in msg) && ("id" in msg) && msg.id !== undefined).length > 0;
37915
+ const contentType = response.headers.get("content-type");
37916
+ if (hasRequests) {
37917
+ if (contentType?.includes("text/event-stream")) {
37918
+ this._handleSseStream(response.body, { onresumptiontoken }, false);
37919
+ } else if (contentType?.includes("application/json")) {
37920
+ const data = await response.json();
37921
+ const responseMessages = Array.isArray(data) ? data.map((msg) => JSONRPCMessageSchema.parse(msg)) : [JSONRPCMessageSchema.parse(data)];
37922
+ for (const msg of responseMessages) {
37923
+ this.onmessage?.(msg);
37924
+ }
37925
+ } else {
37926
+ await response.body?.cancel();
37927
+ throw new StreamableHTTPError(-1, `Unexpected content type: ${contentType}`);
37928
+ }
37929
+ } else {
37930
+ await response.body?.cancel();
37931
+ }
37932
+ } catch (error92) {
37933
+ this.onerror?.(error92);
37934
+ throw error92;
37935
+ }
37936
+ }
37937
+ get sessionId() {
37938
+ return this._sessionId;
37939
+ }
37940
+ async terminateSession() {
37941
+ if (!this._sessionId) {
37942
+ return;
37943
+ }
37944
+ try {
37945
+ const headers = await this._commonHeaders();
37946
+ const init = {
37947
+ ...this._requestInit,
37948
+ method: "DELETE",
37949
+ headers,
37950
+ signal: this._abortController?.signal
37951
+ };
37952
+ const response = await (this._fetch ?? fetch)(this._url, init);
37953
+ await response.body?.cancel();
37954
+ if (!response.ok && response.status !== 405) {
37955
+ throw new StreamableHTTPError(response.status, `Failed to terminate session: ${response.statusText}`);
37956
+ }
37957
+ this._sessionId = undefined;
37958
+ } catch (error92) {
37959
+ this.onerror?.(error92);
37960
+ throw error92;
37961
+ }
37962
+ }
37963
+ setProtocolVersion(version3) {
37964
+ this._protocolVersion = version3;
37965
+ }
37966
+ get protocolVersion() {
37967
+ return this._protocolVersion;
37968
+ }
37969
+ async resumeStream(lastEventId, options) {
37970
+ await this._startOrAuthSse({
37971
+ resumptionToken: lastEventId,
37972
+ onresumptiontoken: options?.onresumptiontoken
37973
+ });
37974
+ }
37975
+ }
37976
+
37587
37977
  // src/mcp-client/remote.ts
37588
37978
  class RemoteMCPClient {
37589
37979
  client;
@@ -37591,12 +37981,17 @@ class RemoteMCPClient {
37591
37981
  toolsCache;
37592
37982
  name;
37593
37983
  config;
37984
+ transportType;
37594
37985
  constructor(config3) {
37595
37986
  this.transport = null;
37596
37987
  this.toolsCache = null;
37597
37988
  this.name = config3.name;
37598
37989
  this.config = config3;
37599
- this.client = new Client({
37990
+ this.transportType = null;
37991
+ this.client = this.createClient();
37992
+ }
37993
+ createClient() {
37994
+ return new Client({
37600
37995
  name: `opencode-toolbox-client-${this.name}`,
37601
37996
  version: "0.1.0"
37602
37997
  }, {});
@@ -37606,12 +38001,46 @@ class RemoteMCPClient {
37606
38001
  throw new Error(`Remote MCP server ${this.name} has no URL`);
37607
38002
  }
37608
38003
  const url3 = new URL(this.config.url);
37609
- this.transport = new SSEClientTransport(url3, {
37610
- requestInit: {
37611
- headers: this.config.headers
38004
+ this.transportType = null;
38005
+ let streamableTransport = null;
38006
+ try {
38007
+ streamableTransport = new StreamableHTTPClientTransport(url3, {
38008
+ requestInit: {
38009
+ headers: this.config.headers
38010
+ }
38011
+ });
38012
+ await this.client.connect(streamableTransport);
38013
+ this.transport = streamableTransport;
38014
+ this.transportType = "streamable-http";
38015
+ return;
38016
+ } catch (error92) {
38017
+ if (streamableTransport) {
38018
+ await streamableTransport.close().catch(() => {});
37612
38019
  }
37613
- });
37614
- await this.client.connect(this.transport);
38020
+ this.client = this.createClient();
38021
+ }
38022
+ let sseTransport = null;
38023
+ try {
38024
+ const sseHeaders = {
38025
+ Accept: "text/event-stream",
38026
+ ...this.config.headers
38027
+ };
38028
+ sseTransport = new SSEClientTransport(url3, {
38029
+ requestInit: {
38030
+ headers: sseHeaders
38031
+ }
38032
+ });
38033
+ await this.client.connect(sseTransport);
38034
+ this.transport = sseTransport;
38035
+ this.transportType = "sse";
38036
+ } catch (error92) {
38037
+ if (sseTransport) {
38038
+ await sseTransport.close().catch(() => {});
38039
+ }
38040
+ this.transport = null;
38041
+ this.transportType = null;
38042
+ throw error92;
38043
+ }
37615
38044
  }
37616
38045
  async listTools() {
37617
38046
  const result = await this.client.listTools();
@@ -37630,10 +38059,14 @@ class RemoteMCPClient {
37630
38059
  this.transport = null;
37631
38060
  }
37632
38061
  this.toolsCache = null;
38062
+ this.transportType = null;
37633
38063
  }
37634
38064
  getCachedTools() {
37635
38065
  return this.toolsCache;
37636
38066
  }
38067
+ getTransportType() {
38068
+ return this.transportType;
38069
+ }
37637
38070
  }
37638
38071
  // src/mcp-client/manager.ts
37639
38072
  import { EventEmitter } from "events";
@@ -38294,7 +38727,7 @@ function formatSearchResults(results, allTools) {
38294
38727
  schema: catalogTool?.inputSchema || null
38295
38728
  };
38296
38729
  }),
38297
- usage: "Use toolbox_execute({ name: '<tool_name>', arguments: '<json>' }) to run a discovered tool"
38730
+ usage: "Use toolbox_execute({ toolId: '<toolId>', arguments: '<json>' }) to run a discovered tool"
38298
38731
  };
38299
38732
  return JSON.stringify(output, null, 2);
38300
38733
  }
@@ -38308,13 +38741,90 @@ Use when you know part of a tool name or server prefix (e.g., "time_.*", "exa_.*
38308
38741
  Returns tools with schemas. Use toolbox_execute() to run them.`;
38309
38742
  var EXECUTE_DESC = `Execute a tool discovered via toolbox_search_bm25 or toolbox_search_regex.
38310
38743
 
38311
- Pass arguments as JSON string matching the tool's schema.`;
38744
+ Pass arguments as JSON string matching the tool's schema.
38745
+ toolId format: {serverName}_{toolName}`;
38312
38746
  var STATUS_DESC = `Get toolbox status including plugin initialization, MCP server connections, and tool counts.
38313
38747
 
38314
38748
  Shows success/total metrics to highlight failures. Use to check if toolbox is working correctly.`;
38315
38749
  var PERF_DESC = `Get detailed performance metrics for the toolbox plugin.
38316
38750
 
38317
38751
  Shows initialization times, search latencies, execution stats, and per-server metrics.`;
38752
+ var TEST_DESC = `Test all toolbox tools with minimal predefined prompts.
38753
+
38754
+ Executes every registered tool with super simple inputs to verify they work. Returns pass/fail for each tool.`;
38755
+ var TEST_PROMPTS = {
38756
+ time_get_current_time: {},
38757
+ time_convert_time: {
38758
+ source_timezone: "UTC",
38759
+ time: "12:00",
38760
+ target_timezone: "America/New_York"
38761
+ },
38762
+ brave_brave_web_search: { query: "test", count: 1 },
38763
+ brave_brave_local_search: { query: "coffee", count: 1 },
38764
+ brave_brave_video_search: { query: "test", count: 1 },
38765
+ brave_brave_image_search: { query: "test", count: 1 },
38766
+ brave_brave_news_search: { query: "test", count: 1 },
38767
+ brave_brave_summarizer: { key: "test" },
38768
+ brightdata_search_engine: { query: "hello", engine: "google", count: 1 },
38769
+ brightdata_search_engine_batch: {
38770
+ queries: [{ query: "test", engine: "google", count: 1 }]
38771
+ },
38772
+ brightdata_scrape_as_markdown: { url: "https://example.com" },
38773
+ brightdata_scrape_as_html: { url: "https://example.com" },
38774
+ brightdata_scrape_batch: { urls: ["https://example.com"] },
38775
+ brightdata_extract: { url: "https://example.com" },
38776
+ brightdata_session_stats: {},
38777
+ brightdata_web_data_reuter_news: {
38778
+ url: "https://www.reuters.com/technology/"
38779
+ },
38780
+ brightdata_web_data_github_repository_file: {
38781
+ url: "https://github.com/octocat/Hello-World/blob/master/README"
38782
+ },
38783
+ "tavily_tavily-search": { query: "test", maxResults: 1 },
38784
+ "tavily_tavily-extract": { urls: ["https://example.com"] },
38785
+ "tavily_tavily-map": { url: "https://example.com" },
38786
+ "context7_resolve-library-id": { libraryName: "react" },
38787
+ octocode_githubSearchRepositories: { query: "test", maxResults: 1 },
38788
+ octocode_githubSearchCode: { query: "function test", maxResults: 1 },
38789
+ octocode_githubViewRepoStructure: { owner: "octocat", repo: "Hello-World" },
38790
+ perplexity_perplexity_ask: { query: "What is 1+1?" },
38791
+ perplexity_perplexity_search: { query: "test", maxResults: 1 }
38792
+ };
38793
+ function generateMinimalArgs(schema2) {
38794
+ const args = {};
38795
+ if (schema2.type !== "object" || !schema2.properties) {
38796
+ return args;
38797
+ }
38798
+ const properties = schema2.properties;
38799
+ const required3 = schema2.required || [];
38800
+ for (const propName of required3) {
38801
+ const prop = properties[propName];
38802
+ if (!prop)
38803
+ continue;
38804
+ const enumValues = prop.enum;
38805
+ switch (prop.type) {
38806
+ case "string":
38807
+ args[propName] = prop.default ?? enumValues?.[0] ?? "test";
38808
+ break;
38809
+ case "number":
38810
+ case "integer":
38811
+ args[propName] = prop.default ?? prop.minimum ?? 1;
38812
+ break;
38813
+ case "boolean":
38814
+ args[propName] = prop.default ?? false;
38815
+ break;
38816
+ case "array":
38817
+ args[propName] = prop.default ?? [];
38818
+ break;
38819
+ case "object":
38820
+ args[propName] = prop.default ?? {};
38821
+ break;
38822
+ default:
38823
+ args[propName] = prop.default ?? null;
38824
+ }
38825
+ }
38826
+ return args;
38827
+ }
38318
38828
  var isTestEnv = !!process.env.BUN_TEST;
38319
38829
  function log(level, message, extra) {
38320
38830
  if (isTestEnv)
@@ -38332,37 +38842,56 @@ function ensureCommandFile() {
38332
38842
  mkdir(COMMAND_DIR, { recursive: true }).then(() => writeFile(COMMAND_FILE_PATH, COMMAND_CONTENT)).then(() => log("info", "Created /toolbox-status command file")).catch(() => {});
38333
38843
  });
38334
38844
  }
38335
- var SYSTEM_PROMPT_BASE = `# Extended Toolbox
38336
-
38337
- You have access to an extended toolbox with additional capabilities (web search, time utilities, code search, etc.).
38338
-
38339
- ## Rules
38340
- 1. ALWAYS toolbox_search_* before saying "I cannot do that" or "I don't have access to."
38341
- 2. ALWAYS toolbox_search_* if you think that user wants you to use some tools
38342
- 3. ALWAYS toolbox_search_* if you think that user may refer specific tool name which is not exist in the context
38343
-
38344
- ## Workflow
38345
- 1. Search: toolbox_search_bm25({ text: "what you need" }) or toolbox_search_regex({ pattern: "prefix_.*" })
38346
- 2. Execute: toolbox_execute({ name: "tool_name", arguments: '{"key": "value"}' })`;
38347
- function generateSystemPrompt(mcpManager) {
38348
- const servers = mcpManager.getAllServers();
38349
- if (servers.length === 0) {
38350
- return SYSTEM_PROMPT_BASE;
38351
- }
38352
- const toolboxSchema = {};
38353
- for (const server of servers) {
38354
- if (server.status === "connected" && server.tools.length > 0) {
38355
- toolboxSchema[server.name] = server.tools.map((t) => t.idString);
38356
- }
38357
- }
38358
- if (Object.keys(toolboxSchema).length === 0) {
38359
- return SYSTEM_PROMPT_BASE;
38360
- }
38361
- return `${SYSTEM_PROMPT_BASE}
38362
-
38363
- ## Registered MCP Servers
38364
- ${Object.entries(toolboxSchema).map(([server, tools]) => `- ${server}: ${tools.map((t) => t.split("_").slice(1).join("_")).join(", ")}`).join(`
38365
- `)}`;
38845
+ function generateSystemPrompt(configuredServers) {
38846
+ const registry3 = configuredServers.length > 0 ? configuredServers.map((s) => `${s}_*`).join(`
38847
+ `) : "(no servers configured)";
38848
+ return `
38849
+ <MCPTools>
38850
+ <Rules>
38851
+ ALWAYS toolbox_search_* before saying "I cannot do that" or "I don't have access to"
38852
+ ALWAYS toolbox_search_* if user wants to use tools or refers to unknown tool names
38853
+ </Rules>
38854
+ <MCPServers>
38855
+ <Registry>
38856
+ ${registry3}
38857
+ </Registry>
38858
+ <NamingConvention>
38859
+ serverName: MCP server name
38860
+ toolName: tool name provided by MCP server
38861
+ toolId: {serverName}_{toolName}
38862
+ </NamingConvention>
38863
+ <Patterns>
38864
+ ALL: ".*"
38865
+ SERVER: "{serverName}_.*"
38866
+ TOOL: "{serverName}_{toolName}"
38867
+ </Patterns>
38868
+ <Discovery>
38869
+ <ListAllTools>
38870
+ toolbox_search_regex({ pattern: ".*" })
38871
+ </ListAllTools>
38872
+ <ServerTools>
38873
+ toolbox_search_regex({ pattern: "serverName_.*" })
38874
+ </ServerTools>
38875
+ <FreeSearch>
38876
+ toolbox_search_bm25({ text: "description keywords" })
38877
+ </FreeSearch>
38878
+ </Discovery>
38879
+ <Execute>
38880
+ toolbox_execute({ toolId: "toolId", arguments: '{}' })
38881
+ </Execute>
38882
+ <When>
38883
+ regex: know server name or partial tool name
38884
+ bm25: know what you want to do, not tool name
38885
+ </When>
38886
+ <Fallback>
38887
+ toolbox_search_regex \u2192 toolbox_search_bm25 \u2192 toolbox_status \u2192 ask user
38888
+ </Fallback>
38889
+ <Troubleshoot>
38890
+ If tool not found: check server prefix, try bm25 with descriptive text
38891
+ Check server health: toolbox_status()
38892
+ </Troubleshoot>
38893
+ </MCPServers>
38894
+ </MCPTools>`;
38366
38895
  }
38367
38896
  var ToolboxPlugin = async (ctx) => {
38368
38897
  const pluginLoadStart = performance.now();
@@ -38510,7 +39039,7 @@ var ToolboxPlugin = async (ctx) => {
38510
39039
  toolbox_execute: tool({
38511
39040
  description: EXECUTE_DESC,
38512
39041
  args: {
38513
- name: tool.schema.string().describe("Full tool name from search results (e.g., 'time_get_current_time', 'exa_web_search')"),
39042
+ toolId: tool.schema.string().describe("Tool ID from search results. Format: {serverName}_{toolName} (e.g., 'time_get_current_time', 'brave_web_search')"),
38514
39043
  arguments: tool.schema.string().optional().describe("JSON-encoded arguments for the tool, matching its schema. Use '{}' or omit for tools with no required arguments.")
38515
39044
  },
38516
39045
  async execute(args) {
@@ -38524,15 +39053,15 @@ var ToolboxPlugin = async (ctx) => {
38524
39053
  error: `Failed to initialize: ${error92 instanceof Error ? error92.message : String(error92)}`
38525
39054
  });
38526
39055
  }
38527
- const parsed = parseToolName(args.name);
39056
+ const parsed = parseToolName(args.toolId);
38528
39057
  if (!parsed) {
38529
39058
  timer();
38530
- log("warn", `Invalid tool name format: ${args.name}`, {
38531
- toolName: args.name
39059
+ log("warn", `Invalid toolId format: ${args.toolId}`, {
39060
+ toolId: args.toolId
38532
39061
  });
38533
39062
  return JSON.stringify({
38534
39063
  success: false,
38535
- error: `Invalid tool name format: ${args.name}. Expected format: serverName_toolName (e.g., 'time_get_current_time')`
39064
+ error: `Invalid toolId format: ${args.toolId}. Expected format: {serverName}_{toolName} (e.g., 'time_get_current_time')`
38536
39065
  });
38537
39066
  }
38538
39067
  let toolArgs = {};
@@ -38541,8 +39070,8 @@ var ToolboxPlugin = async (ctx) => {
38541
39070
  toolArgs = JSON.parse(args.arguments);
38542
39071
  } catch (error92) {
38543
39072
  timer();
38544
- log("warn", `Failed to parse arguments as JSON for ${args.name}`, {
38545
- toolName: args.name,
39073
+ log("warn", `Failed to parse arguments as JSON for ${args.toolId}`, {
39074
+ toolId: args.toolId,
38546
39075
  arguments: args.arguments
38547
39076
  });
38548
39077
  return JSON.stringify({
@@ -38556,7 +39085,7 @@ var ToolboxPlugin = async (ctx) => {
38556
39085
  const result = await mcpManager.callTool(parsed.serverName, parsed.toolName, toolArgs);
38557
39086
  const duration5 = timer();
38558
39087
  executionSuccessCount++;
38559
- log("info", `Tool executed successfully: ${args.name} in ${duration5.toFixed(2)}ms`, {
39088
+ log("info", `Tool executed successfully: ${args.toolId} in ${duration5.toFixed(2)}ms`, {
38560
39089
  server: parsed.serverName,
38561
39090
  tool: parsed.toolName,
38562
39091
  durationMs: duration5
@@ -38663,15 +39192,137 @@ var ToolboxPlugin = async (ctx) => {
38663
39192
  }
38664
39193
  }, null, 2);
38665
39194
  }
39195
+ }),
39196
+ toolbox_test: tool({
39197
+ description: TEST_DESC,
39198
+ args: {
39199
+ timeout: tool.schema.number().optional().describe("Timeout per tool in ms (default: 10000)")
39200
+ },
39201
+ async execute(args) {
39202
+ const startTime = performance.now();
39203
+ const output = [];
39204
+ output.push("=".repeat(80));
39205
+ output.push("TOOLBOX TEST - Full Execution Log");
39206
+ output.push("=".repeat(80));
39207
+ output.push("");
39208
+ try {
39209
+ await ensureInitialized();
39210
+ } catch (error92) {
39211
+ output.push(`[FATAL] Failed to initialize: ${error92 instanceof Error ? error92.message : String(error92)}`);
39212
+ return output.join(`
39213
+ `);
39214
+ }
39215
+ const allTools = mcpManager.getAllCatalogTools();
39216
+ const timeout = args.timeout || 1e4;
39217
+ output.push(`[INFO] Found ${allTools.length} tools to test`);
39218
+ output.push(`[INFO] Timeout per tool: ${timeout}ms`);
39219
+ output.push(`[INFO] Started at: ${new Date().toISOString()}`);
39220
+ output.push("");
39221
+ let passed = 0;
39222
+ let failed = 0;
39223
+ let timedOut = 0;
39224
+ let skipped = 0;
39225
+ for (let i = 0;i < allTools.length; i++) {
39226
+ const catalogTool = allTools[i];
39227
+ const toolId = catalogTool.idString;
39228
+ const testNum = i + 1;
39229
+ output.push("-".repeat(80));
39230
+ output.push(`[TEST ${testNum}/${allTools.length}] ${toolId}`);
39231
+ output.push("-".repeat(80));
39232
+ const parsed = parseToolName(toolId);
39233
+ if (!parsed) {
39234
+ output.push(`[SKIP] Invalid tool name format`);
39235
+ output.push("");
39236
+ skipped++;
39237
+ continue;
39238
+ }
39239
+ output.push(`[INFO] Server: ${parsed.serverName}`);
39240
+ output.push(`[INFO] Tool: ${parsed.toolName}`);
39241
+ output.push(`[INFO] Description: ${catalogTool.description || "(no description)"}`);
39242
+ output.push("");
39243
+ let testArgs;
39244
+ let argsSource;
39245
+ const predefinedArgs = TEST_PROMPTS[toolId];
39246
+ if (predefinedArgs !== undefined) {
39247
+ testArgs = predefinedArgs;
39248
+ argsSource = "PREDEFINED";
39249
+ } else {
39250
+ testArgs = generateMinimalArgs(catalogTool.inputSchema);
39251
+ argsSource = Object.keys(testArgs).length > 0 ? "GENERATED" : "EMPTY";
39252
+ }
39253
+ output.push(`[INPUT] Arguments source: ${argsSource}`);
39254
+ output.push(`[INPUT] Request payload:`);
39255
+ output.push(JSON.stringify(testArgs, null, 2).split(`
39256
+ `).map((line) => " " + line).join(`
39257
+ `));
39258
+ output.push("");
39259
+ const toolStart = performance.now();
39260
+ try {
39261
+ const timeoutPromise = new Promise((_, reject) => {
39262
+ setTimeout(() => reject(new Error("TIMEOUT")), timeout);
39263
+ });
39264
+ const execPromise = mcpManager.callTool(parsed.serverName, parsed.toolName, testArgs);
39265
+ const result = await Promise.race([execPromise, timeoutPromise]);
39266
+ const duration5 = Math.round(performance.now() - toolStart);
39267
+ output.push(`[OUTPUT] Response received in ${duration5}ms:`);
39268
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result, null, 2);
39269
+ output.push(resultStr.split(`
39270
+ `).map((line) => " " + line).join(`
39271
+ `));
39272
+ output.push("");
39273
+ output.push(`[PASS] \u2713 Test passed in ${duration5}ms`);
39274
+ passed++;
39275
+ } catch (error92) {
39276
+ const duration5 = Math.round(performance.now() - toolStart);
39277
+ const errorMsg = error92 instanceof Error ? error92.message : String(error92);
39278
+ if (errorMsg === "TIMEOUT") {
39279
+ output.push(`[OUTPUT] No response - timed out after ${timeout}ms`);
39280
+ output.push("");
39281
+ output.push(`[TIMEOUT] \u2717 Test timed out after ${duration5}ms`);
39282
+ timedOut++;
39283
+ } else {
39284
+ output.push(`[OUTPUT] Error response:`);
39285
+ output.push(` ${errorMsg}`);
39286
+ output.push("");
39287
+ output.push(`[FAIL] \u2717 Test failed in ${duration5}ms`);
39288
+ output.push(`[FAIL] Error: ${errorMsg}`);
39289
+ failed++;
39290
+ }
39291
+ }
39292
+ output.push("");
39293
+ }
39294
+ const totalDuration = Math.round(performance.now() - startTime);
39295
+ const total = allTools.length;
39296
+ const successRate = total > 0 ? Math.round(passed / total * 100) : 0;
39297
+ output.push("=".repeat(80));
39298
+ output.push("TEST SUMMARY");
39299
+ output.push("=".repeat(80));
39300
+ output.push("");
39301
+ output.push(`Total tests: ${total}`);
39302
+ output.push(`Passed: ${passed} \u2713`);
39303
+ output.push(`Failed: ${failed} \u2717`);
39304
+ output.push(`Timed out: ${timedOut} \u23F1`);
39305
+ output.push(`Skipped: ${skipped} \u2298`);
39306
+ output.push("");
39307
+ output.push(`Success rate: ${successRate}%`);
39308
+ output.push(`Total duration: ${totalDuration}ms`);
39309
+ output.push(`Finished at: ${new Date().toISOString()}`);
39310
+ output.push("");
39311
+ output.push("=".repeat(80));
39312
+ log("info", `Toolbox test completed: ${passed}/${total} passed in ${totalDuration}ms`, {
39313
+ passed,
39314
+ failed,
39315
+ timedOut,
39316
+ skipped,
39317
+ total
39318
+ });
39319
+ return output.join(`
39320
+ `);
39321
+ }
38666
39322
  })
38667
39323
  },
38668
39324
  "experimental.chat.system.transform": async (_input, output) => {
38669
- if (!mcpManager.isReady() && initMode === "eager") {
38670
- try {
38671
- await mcpManager.waitForPartial();
38672
- } catch {}
38673
- }
38674
- output.system.push(generateSystemPrompt(mcpManager));
39325
+ output.system.push(generateSystemPrompt(serverNames));
38675
39326
  }
38676
39327
  };
38677
39328
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-toolbox",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Tool Search Tool Plugin for OpenCode - search and execute tools from MCP servers on-demand",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",