@poncho-ai/harness 0.17.0 → 0.18.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.17.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.18.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > tsup src/index.ts --format esm --dts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -7,8 +7,8 @@
7
7
  CLI tsup v8.5.1
8
8
  CLI Target: es2022
9
9
  ESM Build start
10
- ESM dist/index.js 196.31 KB
11
- ESM ⚡️ Build success in 155ms
10
+ ESM dist/index.js 196.78 KB
11
+ ESM ⚡️ Build success in 133ms
12
12
  DTS Build start
13
- DTS ⚡️ Build success in 6569ms
14
- DTS dist/index.d.ts 23.97 KB
13
+ DTS ⚡️ Build success in 6813ms
14
+ DTS dist/index.d.ts 24.01 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`cd6ccd7`](https://github.com/cesr/poncho-ai/commit/cd6ccd7846e16fbaf17167617666796320ec29ce) Thanks [@cesr](https://github.com/cesr)! - Add MCP custom headers support, tool:generating streaming feedback, and cross-owner subagent recovery
8
+ - **MCP custom headers**: `poncho mcp add --header "Name: value"` and `headers` config field let servers like Arcade receive extra HTTP headers alongside bearer auth.
9
+ - **tool:generating event**: the harness now emits `tool:generating` events when the model begins writing tool-call arguments, so the web UI shows real-time "preparing <tool>" feedback instead of appearing stuck during large tool calls.
10
+ - **Subagent recovery**: `list`/`listSummaries` accept optional `ownerId` so stale-subagent recovery on server restart scans across all owners.
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies [[`cd6ccd7`](https://github.com/cesr/poncho-ai/commit/cd6ccd7846e16fbaf17167617666796320ec29ce)]:
15
+ - @poncho-ai/sdk@1.3.0
16
+
3
17
  ## 0.17.0
4
18
 
5
19
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -205,6 +205,7 @@ interface RemoteMcpServerConfig {
205
205
  type: "bearer";
206
206
  tokenEnv?: string;
207
207
  };
208
+ headers?: Record<string, string>;
208
209
  timeoutMs?: number;
209
210
  reconnectAttempts?: number;
210
211
  reconnectDelayMs?: number;
package/dist/index.js CHANGED
@@ -1306,16 +1306,19 @@ var StreamableHttpMcpRpcClient = class {
1306
1306
  endpoint;
1307
1307
  timeoutMs;
1308
1308
  bearerToken;
1309
+ customHeaders;
1309
1310
  idCounter = 1;
1310
1311
  initialized = false;
1311
1312
  sessionId;
1312
- constructor(endpoint, timeoutMs = 1e4, bearerToken) {
1313
+ constructor(endpoint, timeoutMs = 1e4, bearerToken, customHeaders) {
1313
1314
  this.endpoint = endpoint;
1314
1315
  this.timeoutMs = timeoutMs;
1315
1316
  this.bearerToken = bearerToken;
1317
+ this.customHeaders = customHeaders ?? {};
1316
1318
  }
1317
1319
  buildHeaders(accept) {
1318
1320
  const headers = {
1321
+ ...this.customHeaders,
1319
1322
  "Content-Type": "application/json",
1320
1323
  Accept: accept
1321
1324
  };
@@ -1596,7 +1599,8 @@ var LocalMcpBridge = class {
1596
1599
  new StreamableHttpMcpRpcClient(
1597
1600
  server.url,
1598
1601
  server.timeoutMs ?? 1e4,
1599
- server.auth?.tokenEnv ? process.env[server.auth.tokenEnv] : void 0
1602
+ server.auth?.tokenEnv ? process.env[server.auth.tokenEnv] : void 0,
1603
+ server.headers
1600
1604
  )
1601
1605
  );
1602
1606
  }
@@ -4063,7 +4067,7 @@ ${textContent}` };
4063
4067
  let chunkCount = 0;
4064
4068
  const hasRunTimeout = timeoutMs > 0;
4065
4069
  const streamDeadline = hasRunTimeout ? start + timeoutMs : 0;
4066
- const textIterator = result.textStream[Symbol.asyncIterator]();
4070
+ const fullStreamIterator = result.fullStream[Symbol.asyncIterator]();
4067
4071
  try {
4068
4072
  while (true) {
4069
4073
  if (isCancelled()) {
@@ -4089,20 +4093,20 @@ ${textContent}` };
4089
4093
  }
4090
4094
  const remaining = hasRunTimeout ? streamDeadline - now() : Infinity;
4091
4095
  const timeout = chunkCount === 0 ? Math.min(remaining, FIRST_CHUNK_TIMEOUT_MS) : hasRunTimeout ? remaining : 0;
4092
- let nextChunk;
4096
+ let nextPart;
4093
4097
  if (timeout <= 0 && chunkCount > 0) {
4094
- nextChunk = await textIterator.next();
4098
+ nextPart = await fullStreamIterator.next();
4095
4099
  } else {
4096
4100
  let timer;
4097
- nextChunk = await Promise.race([
4098
- textIterator.next(),
4101
+ nextPart = await Promise.race([
4102
+ fullStreamIterator.next(),
4099
4103
  new Promise((resolve10) => {
4100
4104
  timer = setTimeout(() => resolve10(null), timeout);
4101
4105
  })
4102
4106
  ]);
4103
4107
  clearTimeout(timer);
4104
4108
  }
4105
- if (nextChunk === null) {
4109
+ if (nextPart === null) {
4106
4110
  const isFirstChunk = chunkCount === 0;
4107
4111
  console.error(
4108
4112
  `[poncho][harness] Stream timeout waiting for ${isFirstChunk ? "first" : "next"} chunk: model="${modelName}", step=${step}, chunks=${chunkCount}, elapsed=${now() - start}ms`
@@ -4120,13 +4124,19 @@ ${textContent}` };
4120
4124
  });
4121
4125
  return;
4122
4126
  }
4123
- if (nextChunk.done) break;
4124
- chunkCount += 1;
4125
- fullText += nextChunk.value;
4126
- yield pushEvent({ type: "model:chunk", content: nextChunk.value });
4127
+ if (nextPart.done) break;
4128
+ const part = nextPart.value;
4129
+ if (part.type === "text-delta") {
4130
+ chunkCount += 1;
4131
+ fullText += part.text;
4132
+ yield pushEvent({ type: "model:chunk", content: part.text });
4133
+ } else if (part.type === "tool-input-start") {
4134
+ chunkCount += 1;
4135
+ yield pushEvent({ type: "tool:generating", tool: part.toolName, toolCallId: part.id });
4136
+ }
4127
4137
  }
4128
4138
  } finally {
4129
- textIterator.return?.(void 0)?.catch?.(() => {
4139
+ fullStreamIterator.return?.(void 0)?.catch?.(() => {
4130
4140
  });
4131
4141
  }
4132
4142
  if (isCancelled()) {
@@ -4693,13 +4703,13 @@ var InMemoryConversationStore = class {
4693
4703
  }
4694
4704
  }
4695
4705
  }
4696
- async list(ownerId = DEFAULT_OWNER) {
4706
+ async list(ownerId) {
4697
4707
  this.purgeExpired();
4698
- return Array.from(this.conversations.values()).filter((conversation) => conversation.ownerId === ownerId).sort((a, b) => b.updatedAt - a.updatedAt);
4708
+ return Array.from(this.conversations.values()).filter((conversation) => !ownerId || conversation.ownerId === ownerId).sort((a, b) => b.updatedAt - a.updatedAt);
4699
4709
  }
4700
- async listSummaries(ownerId = DEFAULT_OWNER) {
4710
+ async listSummaries(ownerId) {
4701
4711
  this.purgeExpired();
4702
- return Array.from(this.conversations.values()).filter((c) => c.ownerId === ownerId).sort((a, b) => b.updatedAt - a.updatedAt).map((c) => ({
4712
+ return Array.from(this.conversations.values()).filter((c) => !ownerId || c.ownerId === ownerId).sort((a, b) => b.updatedAt - a.updatedAt).map((c) => ({
4703
4713
  conversationId: c.conversationId,
4704
4714
  title: c.title,
4705
4715
  updatedAt: c.updatedAt,
@@ -4875,9 +4885,9 @@ var FileConversationStore = class {
4875
4885
  });
4876
4886
  await this.writing;
4877
4887
  }
4878
- async list(ownerId = DEFAULT_OWNER) {
4888
+ async list(ownerId) {
4879
4889
  await this.ensureLoaded();
4880
- const summaries = Array.from(this.conversations.values()).filter((conversation) => conversation.ownerId === ownerId).sort((a, b) => b.updatedAt - a.updatedAt);
4890
+ const summaries = Array.from(this.conversations.values()).filter((conversation) => !ownerId || conversation.ownerId === ownerId).sort((a, b) => b.updatedAt - a.updatedAt);
4881
4891
  const conversations = [];
4882
4892
  for (const summary of summaries) {
4883
4893
  const loaded = await this.readConversationFile(summary.fileName);
@@ -4887,9 +4897,9 @@ var FileConversationStore = class {
4887
4897
  }
4888
4898
  return conversations;
4889
4899
  }
4890
- async listSummaries(ownerId = DEFAULT_OWNER) {
4900
+ async listSummaries(ownerId) {
4891
4901
  await this.ensureLoaded();
4892
- return Array.from(this.conversations.values()).filter((c) => c.ownerId === ownerId).sort((a, b) => b.updatedAt - a.updatedAt).map((c) => ({
4902
+ return Array.from(this.conversations.values()).filter((c) => !ownerId || c.ownerId === ownerId).sort((a, b) => b.updatedAt - a.updatedAt).map((c) => ({
4893
4903
  conversationId: c.conversationId,
4894
4904
  title: c.title,
4895
4905
  updatedAt: c.updatedAt,
@@ -5115,11 +5125,14 @@ var KeyValueConversationStoreBase = class {
5115
5125
  return void 0;
5116
5126
  }
5117
5127
  }
5118
- async list(ownerId = DEFAULT_OWNER) {
5128
+ async list(ownerId) {
5119
5129
  const kv = await this.client();
5120
5130
  if (!kv) {
5121
5131
  return await this.memoryFallback.list(ownerId);
5122
5132
  }
5133
+ if (!ownerId) {
5134
+ return [];
5135
+ }
5123
5136
  const ids = await this.getOwnerConversationIds(ownerId);
5124
5137
  const conversations = [];
5125
5138
  for (const id of ids) {
@@ -5134,11 +5147,14 @@ var KeyValueConversationStoreBase = class {
5134
5147
  }
5135
5148
  return conversations.sort((a, b) => b.updatedAt - a.updatedAt);
5136
5149
  }
5137
- async listSummaries(ownerId = DEFAULT_OWNER) {
5150
+ async listSummaries(ownerId) {
5138
5151
  const kv = await this.client();
5139
5152
  if (!kv) {
5140
5153
  return await this.memoryFallback.listSummaries(ownerId);
5141
5154
  }
5155
+ if (!ownerId) {
5156
+ return [];
5157
+ }
5142
5158
  const ids = await this.getOwnerConversationIds(ownerId);
5143
5159
  const summaries = [];
5144
5160
  for (const id of ids) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,7 +31,7 @@
31
31
  "redis": "^5.10.0",
32
32
  "yaml": "^2.4.0",
33
33
  "zod": "^3.22.0",
34
- "@poncho-ai/sdk": "1.2.0"
34
+ "@poncho-ai/sdk": "1.3.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/mustache": "^4.2.6",
package/src/harness.ts CHANGED
@@ -1682,15 +1682,14 @@ ${boundedMainMemory.trim()}`
1682
1682
  isEnabled: telemetryEnabled && !!this.latitudeTelemetry,
1683
1683
  },
1684
1684
  });
1685
- // Stream text chunksenforce overall run timeout per chunk.
1686
- // The top-of-step timeout check cannot fire while we are
1687
- // blocked inside the textStream async iterator, so we race
1688
- // each next() call against the remaining time budget.
1685
+ // Stream full responseuse fullStream to get visibility into
1686
+ // tool-call generation (tool-input-start) in addition to text deltas.
1687
+ // Enforce overall run timeout per part.
1689
1688
  let fullText = "";
1690
1689
  let chunkCount = 0;
1691
1690
  const hasRunTimeout = timeoutMs > 0;
1692
1691
  const streamDeadline = hasRunTimeout ? start + timeoutMs : 0;
1693
- const textIterator = result.textStream[Symbol.asyncIterator]();
1692
+ const fullStreamIterator = result.fullStream[Symbol.asyncIterator]();
1694
1693
  try {
1695
1694
  while (true) {
1696
1695
  if (isCancelled()) {
@@ -1714,21 +1713,17 @@ ${boundedMainMemory.trim()}`
1714
1713
  return;
1715
1714
  }
1716
1715
  }
1717
- // Use a shorter timeout for the first chunk to detect
1718
- // non-responsive models quickly instead of waiting minutes.
1719
- // When no run timeout is set, only the first chunk is time-bounded.
1720
1716
  const remaining = hasRunTimeout ? streamDeadline - now() : Infinity;
1721
1717
  const timeout = chunkCount === 0
1722
1718
  ? Math.min(remaining, FIRST_CHUNK_TIMEOUT_MS)
1723
1719
  : hasRunTimeout ? remaining : 0;
1724
- let nextChunk: IteratorResult<string> | null;
1720
+ let nextPart: IteratorResult<(typeof result.fullStream) extends AsyncIterable<infer T> ? T : never> | null;
1725
1721
  if (timeout <= 0 && chunkCount > 0) {
1726
- // No time budget — await the stream directly (development mode, no run timeout)
1727
- nextChunk = await textIterator.next();
1722
+ nextPart = await fullStreamIterator.next();
1728
1723
  } else {
1729
1724
  let timer: ReturnType<typeof setTimeout> | undefined;
1730
- nextChunk = await Promise.race([
1731
- textIterator.next(),
1725
+ nextPart = await Promise.race([
1726
+ fullStreamIterator.next(),
1732
1727
  new Promise<null>((resolve) => {
1733
1728
  timer = setTimeout(() => resolve(null), timeout);
1734
1729
  }),
@@ -1736,16 +1731,14 @@ ${boundedMainMemory.trim()}`
1736
1731
  clearTimeout(timer);
1737
1732
  }
1738
1733
 
1739
- if (nextChunk === null) {
1734
+ if (nextPart === null) {
1740
1735
  const isFirstChunk = chunkCount === 0;
1741
1736
  console.error(
1742
1737
  `[poncho][harness] Stream timeout waiting for ${isFirstChunk ? "first" : "next"} chunk: model="${modelName}", step=${step}, chunks=${chunkCount}, elapsed=${now() - start}ms`,
1743
1738
  );
1744
1739
  if (isFirstChunk) {
1745
- // Throw so the step-level retry logic can handle it.
1746
1740
  throw new FirstChunkTimeoutError(modelName, FIRST_CHUNK_TIMEOUT_MS);
1747
1741
  }
1748
- // Mid-stream timeout: not retryable (partial response would be lost)
1749
1742
  yield pushEvent({
1750
1743
  type: "run:error",
1751
1744
  runId,
@@ -1757,14 +1750,20 @@ ${boundedMainMemory.trim()}`
1757
1750
  return;
1758
1751
  }
1759
1752
 
1760
- if (nextChunk.done) break;
1761
- chunkCount += 1;
1762
- fullText += nextChunk.value;
1763
- yield pushEvent({ type: "model:chunk", content: nextChunk.value });
1753
+ if (nextPart.done) break;
1754
+ const part = nextPart.value;
1755
+
1756
+ if (part.type === "text-delta") {
1757
+ chunkCount += 1;
1758
+ fullText += part.text;
1759
+ yield pushEvent({ type: "model:chunk", content: part.text });
1760
+ } else if (part.type === "tool-input-start") {
1761
+ chunkCount += 1;
1762
+ yield pushEvent({ type: "tool:generating", tool: part.toolName, toolCallId: part.id });
1763
+ }
1764
1764
  }
1765
1765
  } finally {
1766
- // Best-effort cleanup of the underlying stream/connection.
1767
- textIterator.return?.(undefined)?.catch?.(() => {});
1766
+ fullStreamIterator.return?.(undefined)?.catch?.(() => {});
1768
1767
  }
1769
1768
 
1770
1769
  if (isCancelled()) {
@@ -1773,8 +1772,6 @@ ${boundedMainMemory.trim()}`
1773
1772
  }
1774
1773
 
1775
1774
  // Check finish reason for error / abnormal completions.
1776
- // textStream silently swallows model-level errors – they only
1777
- // surface through finishReason (or fullStream, which we don't use).
1778
1775
  const finishReason = await result.finishReason;
1779
1776
 
1780
1777
  if (finishReason === "error") {
package/src/mcp.ts CHANGED
@@ -12,6 +12,7 @@ export interface RemoteMcpServerConfig {
12
12
  type: "bearer";
13
13
  tokenEnv?: string;
14
14
  };
15
+ headers?: Record<string, string>;
15
16
  timeoutMs?: number;
16
17
  reconnectAttempts?: number;
17
18
  reconnectDelayMs?: number;
@@ -46,18 +47,26 @@ class StreamableHttpMcpRpcClient implements McpRpcClient {
46
47
  private readonly endpoint: string;
47
48
  private readonly timeoutMs: number;
48
49
  private readonly bearerToken?: string;
50
+ private readonly customHeaders: Record<string, string>;
49
51
  private idCounter = 1;
50
52
  private initialized = false;
51
53
  private sessionId?: string;
52
54
 
53
- constructor(endpoint: string, timeoutMs = 10_000, bearerToken?: string) {
55
+ constructor(
56
+ endpoint: string,
57
+ timeoutMs = 10_000,
58
+ bearerToken?: string,
59
+ customHeaders?: Record<string, string>,
60
+ ) {
54
61
  this.endpoint = endpoint;
55
62
  this.timeoutMs = timeoutMs;
56
63
  this.bearerToken = bearerToken;
64
+ this.customHeaders = customHeaders ?? {};
57
65
  }
58
66
 
59
67
  private buildHeaders(accept: string): Record<string, string> {
60
68
  const headers: Record<string, string> = {
69
+ ...this.customHeaders,
61
70
  "Content-Type": "application/json",
62
71
  Accept: accept,
63
72
  };
@@ -367,6 +376,7 @@ export class LocalMcpBridge {
367
376
  server.url,
368
377
  server.timeoutMs ?? 10_000,
369
378
  server.auth?.tokenEnv ? process.env[server.auth.tokenEnv] : undefined,
379
+ server.headers,
370
380
  ),
371
381
  );
372
382
  }
package/src/state.ts CHANGED
@@ -223,17 +223,17 @@ export class InMemoryConversationStore implements ConversationStore {
223
223
  }
224
224
  }
225
225
 
226
- async list(ownerId = DEFAULT_OWNER): Promise<Conversation[]> {
226
+ async list(ownerId?: string): Promise<Conversation[]> {
227
227
  this.purgeExpired();
228
228
  return Array.from(this.conversations.values())
229
- .filter((conversation) => conversation.ownerId === ownerId)
229
+ .filter((conversation) => !ownerId || conversation.ownerId === ownerId)
230
230
  .sort((a, b) => b.updatedAt - a.updatedAt);
231
231
  }
232
232
 
233
- async listSummaries(ownerId = DEFAULT_OWNER): Promise<ConversationSummary[]> {
233
+ async listSummaries(ownerId?: string): Promise<ConversationSummary[]> {
234
234
  this.purgeExpired();
235
235
  return Array.from(this.conversations.values())
236
- .filter((c) => c.ownerId === ownerId)
236
+ .filter((c) => !ownerId || c.ownerId === ownerId)
237
237
  .sort((a, b) => b.updatedAt - a.updatedAt)
238
238
  .map((c) => ({
239
239
  conversationId: c.conversationId,
@@ -454,10 +454,10 @@ class FileConversationStore implements ConversationStore {
454
454
  await this.writing;
455
455
  }
456
456
 
457
- async list(ownerId = DEFAULT_OWNER): Promise<Conversation[]> {
457
+ async list(ownerId?: string): Promise<Conversation[]> {
458
458
  await this.ensureLoaded();
459
459
  const summaries = Array.from(this.conversations.values())
460
- .filter((conversation) => conversation.ownerId === ownerId)
460
+ .filter((conversation) => !ownerId || conversation.ownerId === ownerId)
461
461
  .sort((a, b) => b.updatedAt - a.updatedAt);
462
462
  const conversations: Conversation[] = [];
463
463
  for (const summary of summaries) {
@@ -469,10 +469,10 @@ class FileConversationStore implements ConversationStore {
469
469
  return conversations;
470
470
  }
471
471
 
472
- async listSummaries(ownerId = DEFAULT_OWNER): Promise<ConversationSummary[]> {
472
+ async listSummaries(ownerId?: string): Promise<ConversationSummary[]> {
473
473
  await this.ensureLoaded();
474
474
  return Array.from(this.conversations.values())
475
- .filter((c) => c.ownerId === ownerId)
475
+ .filter((c) => !ownerId || c.ownerId === ownerId)
476
476
  .sort((a, b) => b.updatedAt - a.updatedAt)
477
477
  .map((c) => ({
478
478
  conversationId: c.conversationId,
@@ -751,11 +751,15 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
751
751
  }
752
752
  }
753
753
 
754
- async list(ownerId = DEFAULT_OWNER): Promise<Conversation[]> {
754
+ async list(ownerId?: string): Promise<Conversation[]> {
755
755
  const kv = await this.client();
756
756
  if (!kv) {
757
757
  return await this.memoryFallback.list(ownerId);
758
758
  }
759
+ if (!ownerId) {
760
+ // KV stores index per-owner; cross-owner listing not supported
761
+ return [];
762
+ }
759
763
  const ids = await this.getOwnerConversationIds(ownerId);
760
764
  const conversations: Conversation[] = [];
761
765
  for (const id of ids) {
@@ -772,11 +776,14 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
772
776
  return conversations.sort((a, b) => b.updatedAt - a.updatedAt);
773
777
  }
774
778
 
775
- async listSummaries(ownerId = DEFAULT_OWNER): Promise<ConversationSummary[]> {
779
+ async listSummaries(ownerId?: string): Promise<ConversationSummary[]> {
776
780
  const kv = await this.client();
777
781
  if (!kv) {
778
782
  return await this.memoryFallback.listSummaries(ownerId);
779
783
  }
784
+ if (!ownerId) {
785
+ return [];
786
+ }
780
787
  const ids = await this.getOwnerConversationIds(ownerId);
781
788
  const summaries: ConversationSummary[] = [];
782
789
  for (const id of ids) {
package/test/mcp.test.ts CHANGED
@@ -126,6 +126,89 @@ describe("mcp bridge protocol transports", () => {
126
126
  expect(deleteSeen).toBe(true);
127
127
  });
128
128
 
129
+ it("sends custom headers alongside bearer token", async () => {
130
+ process.env.LINEAR_TOKEN = "token-123";
131
+ let capturedHeaders: Record<string, string | undefined> = {};
132
+ const server = createServer(async (req, res) => {
133
+ if (req.method === "DELETE") {
134
+ res.statusCode = 200;
135
+ res.end();
136
+ return;
137
+ }
138
+ const chunks: Buffer[] = [];
139
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
140
+ const body = Buffer.concat(chunks).toString("utf8");
141
+ const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
142
+ capturedHeaders = {
143
+ authorization: req.headers.authorization,
144
+ "x-custom-id": req.headers["x-custom-id"] as string | undefined,
145
+ "x-another": req.headers["x-another"] as string | undefined,
146
+ };
147
+ if (payload.method === "initialize") {
148
+ res.setHeader("Content-Type", "application/json");
149
+ res.setHeader("Mcp-Session-Id", "s");
150
+ res.end(
151
+ JSON.stringify({
152
+ jsonrpc: "2.0",
153
+ id: payload.id,
154
+ result: {
155
+ protocolVersion: "2025-03-26",
156
+ capabilities: { tools: { listChanged: true } },
157
+ serverInfo: { name: "remote", version: "1.0.0" },
158
+ },
159
+ }),
160
+ );
161
+ return;
162
+ }
163
+ if (payload.method === "notifications/initialized") {
164
+ res.statusCode = 202;
165
+ res.end();
166
+ return;
167
+ }
168
+ if (payload.method === "tools/list") {
169
+ res.setHeader("Content-Type", "application/json");
170
+ res.end(
171
+ JSON.stringify({
172
+ jsonrpc: "2.0",
173
+ id: payload.id,
174
+ result: {
175
+ tools: [
176
+ { name: "ping", inputSchema: { type: "object", properties: {} } },
177
+ ],
178
+ },
179
+ }),
180
+ );
181
+ return;
182
+ }
183
+ res.statusCode = 404;
184
+ res.end();
185
+ });
186
+ await new Promise<void>((r) => server.listen(0, () => r()));
187
+ const address = server.address();
188
+ if (!address || typeof address === "string") throw new Error("Unexpected address");
189
+
190
+ const bridge = new LocalMcpBridge({
191
+ mcp: [
192
+ {
193
+ name: "custom-headers",
194
+ url: `http://127.0.0.1:${address.port}/mcp`,
195
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
196
+ headers: { "X-Custom-Id": "user-42", "X-Another": "value" },
197
+ },
198
+ ],
199
+ });
200
+
201
+ await bridge.startLocalServers();
202
+ await bridge.discoverTools();
203
+
204
+ expect(capturedHeaders.authorization).toBe("Bearer token-123");
205
+ expect(capturedHeaders["x-custom-id"]).toBe("user-42");
206
+ expect(capturedHeaders["x-another"]).toBe("value");
207
+
208
+ await bridge.stopLocalServers();
209
+ await new Promise<void>((r) => server.close(() => r()));
210
+ });
211
+
129
212
  it("fails fast on duplicate server names", () => {
130
213
  expect(
131
214
  () =>