@poncho-ai/harness 0.33.0 → 0.34.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.33.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.34.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,8 +8,8 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 334.17 KB
12
- ESM ⚡️ Build success in 152ms
11
+ ESM dist/index.js 336.31 KB
12
+ ESM ⚡️ Build success in 164ms
13
13
  DTS Build start
14
- DTS ⚡️ Build success in 7104ms
15
- DTS dist/index.d.ts 33.55 KB
14
+ DTS ⚡️ Build success in 7760ms
15
+ DTS dist/index.d.ts 33.99 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.34.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`3f096f2`](https://github.com/cesr/poncho-ai/commit/3f096f28b9ab797b52f1b725778976929156cce9) Thanks [@cesr](https://github.com/cesr)! - fix: scope MCP tools to skills via server-level claiming
8
+
9
+ MCP tools from configured servers are now globally available by default. When a skill claims any tool from a server via `allowed-tools`, the entire server becomes skill-managed — its tools are only available when the claiming skill is active (or declared in AGENT.md `allowed-tools`).
10
+
11
+ ## 0.33.1
12
+
13
+ ### Patch Changes
14
+
15
+ - [`d8fe87c`](https://github.com/cesr/poncho-ai/commit/d8fe87c68d42878829422750f98e3c70a425e3e3) Thanks [@cesr](https://github.com/cesr)! - fix: OTLP trace exporter reliability and error visibility
16
+ - Use provider instance directly instead of global `trace.getTracer()` to avoid silent failure when another library registers a tracer provider first
17
+ - Append `/v1/traces` to base OTLP endpoints so users can pass either the base URL or the full signal-specific URL
18
+ - Surface HTTP status code and response body on export failures
19
+ - Enable OTel diagnostic logger at WARN level for internal SDK errors
20
+
3
21
  ## 0.33.0
4
22
 
5
23
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -759,6 +759,14 @@ declare class AgentHarness {
759
759
  private getAgentScriptIntent;
760
760
  private getAgentMcpApprovalPatterns;
761
761
  private getAgentScriptApprovalPatterns;
762
+ /**
763
+ * Return the set of MCP server names that have at least one tool claimed by
764
+ * any loaded skill's `allowedTools.mcp`. When ANY skill claims tools from a
765
+ * server, the entire server is considered "skill-managed" — none of its tools
766
+ * are auto-exposed globally; only explicitly declared tools become available
767
+ * (via agent-level allowed-tools or active skill allowed-tools).
768
+ */
769
+ private getSkillManagedMcpServers;
762
770
  private getRequestedMcpPatterns;
763
771
  private getRequestedScriptPatterns;
764
772
  private getRequestedMcpApprovalPatterns;
package/dist/index.js CHANGED
@@ -882,6 +882,7 @@ Connect your Poncho agent to messaging platforms so it responds to @mentions.
882
882
  1. Go to [api.slack.com/apps](https://api.slack.com/apps) and create a new app "From scratch"
883
883
  2. Under **OAuth & Permissions**, add these Bot Token Scopes:
884
884
  - \`app_mentions:read\`
885
+ - \`channels:history\` (needed to fetch thread context when mentioned in a reply)
885
886
  - \`chat:write\`
886
887
  - \`reactions:write\`
887
888
  3. Under **Event Subscriptions**, enable events:
@@ -5343,7 +5344,7 @@ var createSubagentTools = (manager) => [
5343
5344
  ];
5344
5345
 
5345
5346
  // src/harness.ts
5346
- import { trace, context as otelContext, SpanStatusCode, SpanKind } from "@opentelemetry/api";
5347
+ import { trace, context as otelContext, SpanStatusCode, SpanKind, diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
5347
5348
  import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
5348
5349
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
5349
5350
 
@@ -5357,10 +5358,15 @@ function sanitizeEventForLog(event) {
5357
5358
  return value;
5358
5359
  });
5359
5360
  }
5361
+ var TRACES_PATH = "/v1/traces";
5362
+ function ensureTracesPath(url) {
5363
+ if (url.endsWith(TRACES_PATH)) return url;
5364
+ return url.replace(/\/+$/, "") + TRACES_PATH;
5365
+ }
5360
5366
  function normalizeOtlp(opt) {
5361
5367
  if (!opt) return void 0;
5362
- if (typeof opt === "string") return opt ? { url: opt } : void 0;
5363
- return opt.url ? opt : void 0;
5368
+ if (typeof opt === "string") return opt ? { url: ensureTracesPath(opt) } : void 0;
5369
+ return opt.url ? { ...opt, url: ensureTracesPath(opt.url) } : void 0;
5364
5370
  }
5365
5371
  var TelemetryEmitter = class {
5366
5372
  config;
@@ -5414,7 +5420,10 @@ var TelemetryEmitter = class {
5414
5420
  ]
5415
5421
  })
5416
5422
  });
5417
- } catch {
5423
+ } catch (err) {
5424
+ console.warn(
5425
+ `[poncho][telemetry] OTLP log delivery failed: ${err instanceof Error ? err.message : String(err)}`
5426
+ );
5418
5427
  }
5419
5428
  }
5420
5429
  };
@@ -5507,6 +5516,16 @@ var ToolDispatcher = class {
5507
5516
  };
5508
5517
 
5509
5518
  // src/harness.ts
5519
+ function formatOtlpError(err) {
5520
+ if (!(err instanceof Error)) return String(err);
5521
+ const parts = [];
5522
+ const code = err.code;
5523
+ if (code != null) parts.push(`HTTP ${code}`);
5524
+ if (err.message) parts.push(err.message);
5525
+ const data = err.data;
5526
+ if (data) parts.push(data);
5527
+ return parts.join(" \u2014 ") || "unknown error";
5528
+ }
5510
5529
  var now = () => Date.now();
5511
5530
  var FIRST_CHUNK_TIMEOUT_MS = 9e4;
5512
5531
  var MAX_TRANSIENT_STEP_RETRIES = 1;
@@ -6283,6 +6302,25 @@ var AgentHarness = class _AgentHarness {
6283
6302
  getAgentScriptApprovalPatterns() {
6284
6303
  return this.parsedAgent?.frontmatter.approvalRequired?.scripts ?? [];
6285
6304
  }
6305
+ /**
6306
+ * Return the set of MCP server names that have at least one tool claimed by
6307
+ * any loaded skill's `allowedTools.mcp`. When ANY skill claims tools from a
6308
+ * server, the entire server is considered "skill-managed" — none of its tools
6309
+ * are auto-exposed globally; only explicitly declared tools become available
6310
+ * (via agent-level allowed-tools or active skill allowed-tools).
6311
+ */
6312
+ getSkillManagedMcpServers() {
6313
+ const servers = /* @__PURE__ */ new Set();
6314
+ for (const skill of this.loadedSkills) {
6315
+ for (const pattern of skill.allowedTools.mcp) {
6316
+ const slash = pattern.indexOf("/");
6317
+ if (slash > 0) {
6318
+ servers.add(pattern.slice(0, slash));
6319
+ }
6320
+ }
6321
+ }
6322
+ return servers;
6323
+ }
6286
6324
  getRequestedMcpPatterns() {
6287
6325
  const patterns = new Set(this.getAgentMcpIntent());
6288
6326
  for (const skillName of this.activeSkillNames) {
@@ -6294,6 +6332,17 @@ var AgentHarness = class _AgentHarness {
6294
6332
  patterns.add(pattern);
6295
6333
  }
6296
6334
  }
6335
+ if (this.mcpBridge) {
6336
+ const managedServers = this.getSkillManagedMcpServers();
6337
+ const discoveredTools = this.mcpBridge.listDiscoveredTools();
6338
+ for (const toolName of discoveredTools) {
6339
+ const slash = toolName.indexOf("/");
6340
+ const serverName = slash > 0 ? toolName.slice(0, slash) : toolName;
6341
+ if (!managedServers.has(serverName)) {
6342
+ patterns.add(toolName);
6343
+ }
6344
+ }
6345
+ }
6297
6346
  return [...patterns];
6298
6347
  }
6299
6348
  getRequestedScriptPatterns() {
@@ -6603,6 +6652,7 @@ var AgentHarness = class _AgentHarness {
6603
6652
  const telemetryEnabled = config?.telemetry?.enabled !== false;
6604
6653
  const otlpConfig = telemetryEnabled ? normalizeOtlp(config?.telemetry?.otlp) : void 0;
6605
6654
  if (otlpConfig) {
6655
+ diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN);
6606
6656
  const exporter = new OTLPTraceExporter({
6607
6657
  url: otlpConfig.url,
6608
6658
  headers: otlpConfig.headers
@@ -6615,7 +6665,7 @@ var AgentHarness = class _AgentHarness {
6615
6665
  provider2.register();
6616
6666
  this.otlpTracerProvider = provider2;
6617
6667
  this.hasOtlpExporter = true;
6618
- console.info(`[poncho][telemetry] OTLP exporter active \u2192 ${otlpConfig.url}`);
6668
+ console.info(`[poncho][telemetry] OTLP trace exporter active \u2192 ${otlpConfig.url}`);
6619
6669
  }
6620
6670
  }
6621
6671
  async buildBrowserStoragePersistence(config, sessionId) {
@@ -6761,17 +6811,13 @@ var AgentHarness = class _AgentHarness {
6761
6811
  await this.mcpBridge?.stopLocalServers();
6762
6812
  if (this.otlpSpanProcessor) {
6763
6813
  await this.otlpSpanProcessor.shutdown().catch((err) => {
6764
- console.warn(
6765
- `[poncho][telemetry] OTLP span processor shutdown error: ${err instanceof Error ? err.message : String(err)}`
6766
- );
6814
+ console.warn(`[poncho][telemetry] OTLP span processor shutdown error: ${formatOtlpError(err)}`);
6767
6815
  });
6768
6816
  this.otlpSpanProcessor = void 0;
6769
6817
  }
6770
6818
  if (this.otlpTracerProvider) {
6771
6819
  await this.otlpTracerProvider.shutdown().catch((err) => {
6772
- console.warn(
6773
- `[poncho][telemetry] OTLP tracer provider shutdown error: ${err instanceof Error ? err.message : String(err)}`
6774
- );
6820
+ console.warn(`[poncho][telemetry] OTLP tracer provider shutdown error: ${formatOtlpError(err)}`);
6775
6821
  });
6776
6822
  this.otlpTracerProvider = void 0;
6777
6823
  }
@@ -6785,8 +6831,8 @@ var AgentHarness = class _AgentHarness {
6785
6831
  * child spans (LLM calls via AI SDK, tool execution) group under one trace.
6786
6832
  */
6787
6833
  async *runWithTelemetry(input) {
6788
- if (this.hasOtlpExporter) {
6789
- const tracer = trace.getTracer("gen_ai");
6834
+ if (this.hasOtlpExporter && this.otlpTracerProvider) {
6835
+ const tracer = this.otlpTracerProvider.getTracer("gen_ai");
6790
6836
  const agentName = this.parsedAgent?.frontmatter.name ?? "agent";
6791
6837
  const rootSpan = tracer.startSpan(`invoke_agent ${agentName}`, {
6792
6838
  kind: SpanKind.INTERNAL,
@@ -6815,7 +6861,9 @@ var AgentHarness = class _AgentHarness {
6815
6861
  rootSpan.end();
6816
6862
  try {
6817
6863
  await this.otlpSpanProcessor?.forceFlush();
6818
- } catch {
6864
+ } catch (err) {
6865
+ const detail = formatOtlpError(err);
6866
+ console.warn(`[poncho][telemetry] OTLP span flush failed: ${detail}`);
6819
6867
  }
6820
6868
  }
6821
6869
  } else {
@@ -7684,8 +7732,8 @@ ${textContent}` };
7684
7732
  return;
7685
7733
  }
7686
7734
  const toolSpans = /* @__PURE__ */ new Map();
7687
- if (this.hasOtlpExporter) {
7688
- const tracer = trace.getTracer("gen_ai");
7735
+ if (this.hasOtlpExporter && this.otlpTracerProvider) {
7736
+ const tracer = this.otlpTracerProvider.getTracer("gen_ai");
7689
7737
  for (const call of approvedCalls) {
7690
7738
  const toolDef = this.dispatcher.get(call.name);
7691
7739
  toolSpans.set(call.id, tracer.startSpan(`execute_tool ${call.name}`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/harness.ts CHANGED
@@ -37,10 +37,22 @@ import { createSkillTools, normalizeScriptPolicyPath } from "./skill-tools.js";
37
37
  import { createSearchTools } from "./search-tools.js";
38
38
  import { createSubagentTools } from "./subagent-tools.js";
39
39
  import type { SubagentManager } from "./subagent-manager.js";
40
- import { trace, context as otelContext, SpanStatusCode, SpanKind } from "@opentelemetry/api";
40
+ import { trace, context as otelContext, SpanStatusCode, SpanKind, diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
41
41
  import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
42
42
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
43
43
  import { normalizeOtlp } from "./telemetry.js";
44
+
45
+ /** Extract useful details from OTLPExporterError (has .code + .data) or plain Error. */
46
+ function formatOtlpError(err: unknown): string {
47
+ if (!(err instanceof Error)) return String(err);
48
+ const parts: string[] = [];
49
+ const code = (err as { code?: number }).code;
50
+ if (code != null) parts.push(`HTTP ${code}`);
51
+ if (err.message) parts.push(err.message);
52
+ const data = (err as { data?: string }).data;
53
+ if (data) parts.push(data);
54
+ return parts.join(" — ") || "unknown error";
55
+ }
44
56
  import {
45
57
  isSiblingScriptsPattern,
46
58
  matchesRelativeScriptPattern,
@@ -983,8 +995,30 @@ export class AgentHarness {
983
995
  return this.parsedAgent?.frontmatter.approvalRequired?.scripts ?? [];
984
996
  }
985
997
 
998
+ /**
999
+ * Return the set of MCP server names that have at least one tool claimed by
1000
+ * any loaded skill's `allowedTools.mcp`. When ANY skill claims tools from a
1001
+ * server, the entire server is considered "skill-managed" — none of its tools
1002
+ * are auto-exposed globally; only explicitly declared tools become available
1003
+ * (via agent-level allowed-tools or active skill allowed-tools).
1004
+ */
1005
+ private getSkillManagedMcpServers(): Set<string> {
1006
+ const servers = new Set<string>();
1007
+ for (const skill of this.loadedSkills) {
1008
+ for (const pattern of skill.allowedTools.mcp) {
1009
+ const slash = pattern.indexOf("/");
1010
+ if (slash > 0) {
1011
+ servers.add(pattern.slice(0, slash));
1012
+ }
1013
+ }
1014
+ }
1015
+ return servers;
1016
+ }
1017
+
986
1018
  private getRequestedMcpPatterns(): string[] {
987
1019
  const patterns = new Set<string>(this.getAgentMcpIntent());
1020
+
1021
+ // Add patterns from active skills.
988
1022
  for (const skillName of this.activeSkillNames) {
989
1023
  const skill = this.loadedSkills.find((entry) => entry.name === skillName);
990
1024
  if (!skill) {
@@ -994,6 +1028,26 @@ export class AgentHarness {
994
1028
  patterns.add(pattern);
995
1029
  }
996
1030
  }
1031
+
1032
+ // MCP servers whose tools are NOT claimed by any skill are "unmanaged" —
1033
+ // all their discovered tools are globally available so that configuring a
1034
+ // server in poncho.config.js makes its tools accessible by default.
1035
+ //
1036
+ // Once ANY skill claims tools from a server (even a single tool), that
1037
+ // server becomes "skill-managed" and ALL of its tools require explicit
1038
+ // declaration (agent-level or active-skill) to be available.
1039
+ if (this.mcpBridge) {
1040
+ const managedServers = this.getSkillManagedMcpServers();
1041
+ const discoveredTools = this.mcpBridge.listDiscoveredTools();
1042
+ for (const toolName of discoveredTools) {
1043
+ const slash = toolName.indexOf("/");
1044
+ const serverName = slash > 0 ? toolName.slice(0, slash) : toolName;
1045
+ if (!managedServers.has(serverName)) {
1046
+ patterns.add(toolName);
1047
+ }
1048
+ }
1049
+ }
1050
+
997
1051
  return [...patterns];
998
1052
  }
999
1053
 
@@ -1346,6 +1400,7 @@ export class AgentHarness {
1346
1400
  const telemetryEnabled = config?.telemetry?.enabled !== false;
1347
1401
  const otlpConfig = telemetryEnabled ? normalizeOtlp(config?.telemetry?.otlp) : undefined;
1348
1402
  if (otlpConfig) {
1403
+ diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN);
1349
1404
  const exporter = new OTLPTraceExporter({
1350
1405
  url: otlpConfig.url,
1351
1406
  headers: otlpConfig.headers,
@@ -1358,7 +1413,7 @@ export class AgentHarness {
1358
1413
  provider.register();
1359
1414
  this.otlpTracerProvider = provider;
1360
1415
  this.hasOtlpExporter = true;
1361
- console.info(`[poncho][telemetry] OTLP exporter active → ${otlpConfig.url}`);
1416
+ console.info(`[poncho][telemetry] OTLP trace exporter active → ${otlpConfig.url}`);
1362
1417
  }
1363
1418
  }
1364
1419
 
@@ -1523,21 +1578,13 @@ export class AgentHarness {
1523
1578
  await this.mcpBridge?.stopLocalServers();
1524
1579
  if (this.otlpSpanProcessor) {
1525
1580
  await this.otlpSpanProcessor.shutdown().catch((err) => {
1526
- console.warn(
1527
- `[poncho][telemetry] OTLP span processor shutdown error: ${
1528
- err instanceof Error ? err.message : String(err)
1529
- }`,
1530
- );
1581
+ console.warn(`[poncho][telemetry] OTLP span processor shutdown error: ${formatOtlpError(err)}`);
1531
1582
  });
1532
1583
  this.otlpSpanProcessor = undefined;
1533
1584
  }
1534
1585
  if (this.otlpTracerProvider) {
1535
1586
  await this.otlpTracerProvider.shutdown().catch((err) => {
1536
- console.warn(
1537
- `[poncho][telemetry] OTLP tracer provider shutdown error: ${
1538
- err instanceof Error ? err.message : String(err)
1539
- }`,
1540
- );
1587
+ console.warn(`[poncho][telemetry] OTLP tracer provider shutdown error: ${formatOtlpError(err)}`);
1541
1588
  });
1542
1589
  this.otlpTracerProvider = undefined;
1543
1590
  }
@@ -1553,8 +1600,8 @@ export class AgentHarness {
1553
1600
  * child spans (LLM calls via AI SDK, tool execution) group under one trace.
1554
1601
  */
1555
1602
  async *runWithTelemetry(input: RunInput): AsyncGenerator<AgentEvent> {
1556
- if (this.hasOtlpExporter) {
1557
- const tracer = trace.getTracer("gen_ai");
1603
+ if (this.hasOtlpExporter && this.otlpTracerProvider) {
1604
+ const tracer = this.otlpTracerProvider.getTracer("gen_ai");
1558
1605
  const agentName = this.parsedAgent?.frontmatter.name ?? "agent";
1559
1606
 
1560
1607
  const rootSpan = tracer.startSpan(`invoke_agent ${agentName}`, {
@@ -1586,7 +1633,10 @@ export class AgentHarness {
1586
1633
  rootSpan.end();
1587
1634
  try {
1588
1635
  await this.otlpSpanProcessor?.forceFlush();
1589
- } catch { /* best-effort */ }
1636
+ } catch (err: unknown) {
1637
+ const detail = formatOtlpError(err);
1638
+ console.warn(`[poncho][telemetry] OTLP span flush failed: ${detail}`);
1639
+ }
1590
1640
  }
1591
1641
  } else {
1592
1642
  yield* this.run(input);
@@ -2642,8 +2692,8 @@ ${boundedMainMemory.trim()}`
2642
2692
  // OTel GenAI execute_tool spans for tool call visibility in traces
2643
2693
  type OtelSpan = ReturnType<ReturnType<typeof trace.getTracer>["startSpan"]>;
2644
2694
  const toolSpans = new Map<string, OtelSpan>();
2645
- if (this.hasOtlpExporter) {
2646
- const tracer = trace.getTracer("gen_ai");
2695
+ if (this.hasOtlpExporter && this.otlpTracerProvider) {
2696
+ const tracer = this.otlpTracerProvider.getTracer("gen_ai");
2647
2697
  for (const call of approvedCalls) {
2648
2698
  const toolDef = this.dispatcher.get(call.name);
2649
2699
  toolSpans.set(call.id, tracer.startSpan(`execute_tool ${call.name}`, {
package/src/telemetry.ts CHANGED
@@ -18,10 +18,26 @@ export interface OtlpConfig {
18
18
 
19
19
  export type OtlpOption = string | OtlpConfig;
20
20
 
21
+ const TRACES_PATH = "/v1/traces";
22
+
23
+ /**
24
+ * Ensures the OTLP URL points to the trace-ingest endpoint.
25
+ *
26
+ * Users typically configure the *base* OTLP endpoint (e.g.
27
+ * `https://gateway.example.com/api/v1/otlp`) but `OTLPTraceExporter` uses
28
+ * the `url` constructor option as-is — it only appends `/v1/traces`
29
+ * automatically when reading from the `OTEL_EXPORTER_OTLP_ENDPOINT` env var.
30
+ * Without this fixup traces are POSTed to the wrong path and silently lost.
31
+ */
32
+ function ensureTracesPath(url: string): string {
33
+ if (url.endsWith(TRACES_PATH)) return url;
34
+ return url.replace(/\/+$/, "") + TRACES_PATH;
35
+ }
36
+
21
37
  export function normalizeOtlp(opt: OtlpOption | undefined): OtlpConfig | undefined {
22
38
  if (!opt) return undefined;
23
- if (typeof opt === "string") return opt ? { url: opt } : undefined;
24
- return opt.url ? opt : undefined;
39
+ if (typeof opt === "string") return opt ? { url: ensureTracesPath(opt) } : undefined;
40
+ return opt.url ? { ...opt, url: ensureTracesPath(opt.url) } : undefined;
25
41
  }
26
42
 
27
43
  export interface TelemetryConfig {
@@ -87,8 +103,12 @@ export class TelemetryEmitter {
87
103
  ],
88
104
  }),
89
105
  });
90
- } catch {
91
- // Ignore telemetry delivery failures.
106
+ } catch (err) {
107
+ console.warn(
108
+ `[poncho][telemetry] OTLP log delivery failed: ${
109
+ err instanceof Error ? err.message : String(err)
110
+ }`,
111
+ );
92
112
  }
93
113
  }
94
114
 
@@ -1037,6 +1037,360 @@ allowed-tools:
1037
1037
  await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
1038
1038
  });
1039
1039
 
1040
+ it("unclaimed MCP tools are globally available without allowed-tools declaration", async () => {
1041
+ process.env.LINEAR_TOKEN = "token-123";
1042
+ const mcpServer = createServer(async (req, res) => {
1043
+ if (req.method === "DELETE") {
1044
+ res.statusCode = 200;
1045
+ res.end();
1046
+ return;
1047
+ }
1048
+ const chunks: Buffer[] = [];
1049
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
1050
+ const body = Buffer.concat(chunks).toString("utf8");
1051
+ const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
1052
+ if (payload.method === "initialize") {
1053
+ res.setHeader("Content-Type", "application/json");
1054
+ res.setHeader("Mcp-Session-Id", "sess");
1055
+ res.end(
1056
+ JSON.stringify({
1057
+ jsonrpc: "2.0",
1058
+ id: payload.id,
1059
+ result: {
1060
+ protocolVersion: "2025-03-26",
1061
+ capabilities: { tools: { listChanged: true } },
1062
+ serverInfo: { name: "remote", version: "1.0.0" },
1063
+ },
1064
+ }),
1065
+ );
1066
+ return;
1067
+ }
1068
+ if (payload.method === "notifications/initialized") {
1069
+ res.statusCode = 202;
1070
+ res.end();
1071
+ return;
1072
+ }
1073
+ if (payload.method === "tools/list") {
1074
+ res.setHeader("Content-Type", "application/json");
1075
+ res.end(
1076
+ JSON.stringify({
1077
+ jsonrpc: "2.0",
1078
+ id: payload.id,
1079
+ result: {
1080
+ tools: [
1081
+ { name: "list_issues", inputSchema: { type: "object", properties: {} } },
1082
+ { name: "save_issue", inputSchema: { type: "object", properties: {} } },
1083
+ ],
1084
+ },
1085
+ }),
1086
+ );
1087
+ return;
1088
+ }
1089
+ res.statusCode = 404;
1090
+ res.end();
1091
+ });
1092
+ await new Promise<void>((resolveOpen) => mcpServer.listen(0, () => resolveOpen()));
1093
+ const address = mcpServer.address();
1094
+ if (!address || typeof address === "string") throw new Error("Unexpected address");
1095
+ const dir = await mkdtemp(join(tmpdir(), "poncho-harness-unclaimed-mcp-"));
1096
+ // AGENT.md with no allowed-tools and no skills — MCP tools should be globally available
1097
+ await writeFile(
1098
+ join(dir, "AGENT.md"),
1099
+ `---
1100
+ name: unclaimed-mcp-agent
1101
+ model:
1102
+ provider: anthropic
1103
+ name: claude-opus-4-5
1104
+ ---
1105
+
1106
+ # Agent with unclaimed MCP tools
1107
+ `,
1108
+ "utf8",
1109
+ );
1110
+ await writeFile(
1111
+ join(dir, "poncho.config.js"),
1112
+ `export default {
1113
+ mcp: [
1114
+ {
1115
+ name: "linear",
1116
+ url: "http://127.0.0.1:${address.port}/mcp",
1117
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" }
1118
+ }
1119
+ ]
1120
+ };
1121
+ `,
1122
+ "utf8",
1123
+ );
1124
+ const harness = new AgentHarness({ workingDir: dir });
1125
+ await harness.initialize();
1126
+ const toolNames = () => harness.listTools().map((t) => t.name);
1127
+ // Unclaimed tools should be globally available
1128
+ expect(toolNames()).toContain("linear/list_issues");
1129
+ expect(toolNames()).toContain("linear/save_issue");
1130
+ await harness.shutdown();
1131
+ await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
1132
+ });
1133
+
1134
+ it("claiming any tool from a server scopes the entire server", async () => {
1135
+ process.env.LINEAR_TOKEN = "token-123";
1136
+ const mcpServer = createServer(async (req, res) => {
1137
+ if (req.method === "DELETE") {
1138
+ res.statusCode = 200;
1139
+ res.end();
1140
+ return;
1141
+ }
1142
+ const chunks: Buffer[] = [];
1143
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
1144
+ const body = Buffer.concat(chunks).toString("utf8");
1145
+ const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
1146
+ if (payload.method === "initialize") {
1147
+ res.setHeader("Content-Type", "application/json");
1148
+ res.setHeader("Mcp-Session-Id", "sess");
1149
+ res.end(
1150
+ JSON.stringify({
1151
+ jsonrpc: "2.0",
1152
+ id: payload.id,
1153
+ result: {
1154
+ protocolVersion: "2025-03-26",
1155
+ capabilities: { tools: { listChanged: true } },
1156
+ serverInfo: { name: "remote", version: "1.0.0" },
1157
+ },
1158
+ }),
1159
+ );
1160
+ return;
1161
+ }
1162
+ if (payload.method === "notifications/initialized") {
1163
+ res.statusCode = 202;
1164
+ res.end();
1165
+ return;
1166
+ }
1167
+ if (payload.method === "tools/list") {
1168
+ res.setHeader("Content-Type", "application/json");
1169
+ res.end(
1170
+ JSON.stringify({
1171
+ jsonrpc: "2.0",
1172
+ id: payload.id,
1173
+ result: {
1174
+ tools: [
1175
+ { name: "list_issues", inputSchema: { type: "object", properties: {} } },
1176
+ { name: "save_issue", inputSchema: { type: "object", properties: {} } },
1177
+ { name: "other_tool", inputSchema: { type: "object", properties: {} } },
1178
+ ],
1179
+ },
1180
+ }),
1181
+ );
1182
+ return;
1183
+ }
1184
+ if (payload.method === "tools/call") {
1185
+ res.setHeader("Content-Type", "application/json");
1186
+ res.end(
1187
+ JSON.stringify({
1188
+ jsonrpc: "2.0",
1189
+ id: payload.id,
1190
+ result: { result: { ok: true } },
1191
+ }),
1192
+ );
1193
+ return;
1194
+ }
1195
+ res.statusCode = 404;
1196
+ res.end();
1197
+ });
1198
+ await new Promise<void>((resolveOpen) => mcpServer.listen(0, () => resolveOpen()));
1199
+ const address = mcpServer.address();
1200
+ if (!address || typeof address === "string") throw new Error("Unexpected address");
1201
+ const dir = await mkdtemp(join(tmpdir(), "poncho-harness-server-scoped-"));
1202
+ await writeFile(
1203
+ join(dir, "AGENT.md"),
1204
+ `---
1205
+ name: server-scoped-agent
1206
+ model:
1207
+ provider: anthropic
1208
+ name: claude-opus-4-5
1209
+ ---
1210
+
1211
+ # Server Scoped Agent
1212
+ `,
1213
+ "utf8",
1214
+ );
1215
+ await writeFile(
1216
+ join(dir, "poncho.config.js"),
1217
+ `export default {
1218
+ mcp: [
1219
+ {
1220
+ name: "remote",
1221
+ url: "http://127.0.0.1:${address.port}/mcp",
1222
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" }
1223
+ }
1224
+ ]
1225
+ };
1226
+ `,
1227
+ "utf8",
1228
+ );
1229
+ // Skill claims only 2 of the 3 tools from "remote" server
1230
+ await mkdir(join(dir, "skills", "linear"), { recursive: true });
1231
+ await writeFile(
1232
+ join(dir, "skills", "linear", "SKILL.md"),
1233
+ `---
1234
+ name: linear
1235
+ description: Linear issue tracking
1236
+ allowed-tools:
1237
+ - mcp:remote/list_issues
1238
+ - mcp:remote/save_issue
1239
+ ---
1240
+ # Linear Skill
1241
+ `,
1242
+ "utf8",
1243
+ );
1244
+ const harness = new AgentHarness({ workingDir: dir });
1245
+ await harness.initialize();
1246
+ const toolNames = () => harness.listTools().map((t) => t.name);
1247
+
1248
+ // Before activation: entire server is skill-managed, so ALL tools are hidden
1249
+ // (even other_tool which the skill didn't explicitly claim)
1250
+ expect(toolNames()).not.toContain("remote/list_issues");
1251
+ expect(toolNames()).not.toContain("remote/save_issue");
1252
+ expect(toolNames()).not.toContain("remote/other_tool");
1253
+
1254
+ // Activate the linear skill — only its declared tools appear
1255
+ const activate = harness.listTools().find((t) => t.name === "activate_skill")!;
1256
+ const deactivate = harness.listTools().find((t) => t.name === "deactivate_skill")!;
1257
+ await activate.handler({ name: "linear" }, {} as any);
1258
+
1259
+ expect(toolNames()).toContain("remote/list_issues");
1260
+ expect(toolNames()).toContain("remote/save_issue");
1261
+ // other_tool is NOT claimed by any active skill, and the server is managed
1262
+ expect(toolNames()).not.toContain("remote/other_tool");
1263
+
1264
+ // Deactivate — all tools from the managed server hidden again
1265
+ await deactivate.handler({ name: "linear" }, {} as any);
1266
+ expect(toolNames()).not.toContain("remote/list_issues");
1267
+ expect(toolNames()).not.toContain("remote/save_issue");
1268
+ expect(toolNames()).not.toContain("remote/other_tool");
1269
+
1270
+ await harness.shutdown();
1271
+ await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
1272
+ });
1273
+
1274
+ it("wildcard skill claim scopes all tools from that server", async () => {
1275
+ process.env.LINEAR_TOKEN = "token-123";
1276
+ const mcpServer = createServer(async (req, res) => {
1277
+ if (req.method === "DELETE") {
1278
+ res.statusCode = 200;
1279
+ res.end();
1280
+ return;
1281
+ }
1282
+ const chunks: Buffer[] = [];
1283
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
1284
+ const body = Buffer.concat(chunks).toString("utf8");
1285
+ const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
1286
+ if (payload.method === "initialize") {
1287
+ res.setHeader("Content-Type", "application/json");
1288
+ res.setHeader("Mcp-Session-Id", "sess");
1289
+ res.end(
1290
+ JSON.stringify({
1291
+ jsonrpc: "2.0",
1292
+ id: payload.id,
1293
+ result: {
1294
+ protocolVersion: "2025-03-26",
1295
+ capabilities: { tools: { listChanged: true } },
1296
+ serverInfo: { name: "linear", version: "1.0.0" },
1297
+ },
1298
+ }),
1299
+ );
1300
+ return;
1301
+ }
1302
+ if (payload.method === "notifications/initialized") {
1303
+ res.statusCode = 202;
1304
+ res.end();
1305
+ return;
1306
+ }
1307
+ if (payload.method === "tools/list") {
1308
+ res.setHeader("Content-Type", "application/json");
1309
+ res.end(
1310
+ JSON.stringify({
1311
+ jsonrpc: "2.0",
1312
+ id: payload.id,
1313
+ result: {
1314
+ tools: [
1315
+ { name: "list_issues", inputSchema: { type: "object", properties: {} } },
1316
+ { name: "save_issue", inputSchema: { type: "object", properties: {} } },
1317
+ { name: "save_comment", inputSchema: { type: "object", properties: {} } },
1318
+ ],
1319
+ },
1320
+ }),
1321
+ );
1322
+ return;
1323
+ }
1324
+ res.statusCode = 404;
1325
+ res.end();
1326
+ });
1327
+ await new Promise<void>((resolveOpen) => mcpServer.listen(0, () => resolveOpen()));
1328
+ const address = mcpServer.address();
1329
+ if (!address || typeof address === "string") throw new Error("Unexpected address");
1330
+ const dir = await mkdtemp(join(tmpdir(), "poncho-harness-wildcard-claim-"));
1331
+ await writeFile(
1332
+ join(dir, "AGENT.md"),
1333
+ `---
1334
+ name: wildcard-agent
1335
+ model:
1336
+ provider: anthropic
1337
+ name: claude-opus-4-5
1338
+ ---
1339
+
1340
+ # Wildcard Agent
1341
+ `,
1342
+ "utf8",
1343
+ );
1344
+ await writeFile(
1345
+ join(dir, "poncho.config.js"),
1346
+ `export default {
1347
+ mcp: [
1348
+ {
1349
+ name: "linear",
1350
+ url: "http://127.0.0.1:${address.port}/mcp",
1351
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" }
1352
+ }
1353
+ ]
1354
+ };
1355
+ `,
1356
+ "utf8",
1357
+ );
1358
+ // Skill claims all linear tools with wildcard
1359
+ await mkdir(join(dir, "skills", "linear"), { recursive: true });
1360
+ await writeFile(
1361
+ join(dir, "skills", "linear", "SKILL.md"),
1362
+ `---
1363
+ name: linear
1364
+ description: Linear integration
1365
+ allowed-tools:
1366
+ - mcp:linear/*
1367
+ ---
1368
+ # Linear Skill
1369
+ `,
1370
+ "utf8",
1371
+ );
1372
+ const harness = new AgentHarness({ workingDir: dir });
1373
+ await harness.initialize();
1374
+ const toolNames = () => harness.listTools().map((t) => t.name);
1375
+
1376
+ // None of the linear tools should be available (all claimed by wildcard)
1377
+ expect(toolNames()).not.toContain("linear/list_issues");
1378
+ expect(toolNames()).not.toContain("linear/save_issue");
1379
+ expect(toolNames()).not.toContain("linear/save_comment");
1380
+
1381
+ // Activate the skill
1382
+ const activate = harness.listTools().find((t) => t.name === "activate_skill")!;
1383
+ await activate.handler({ name: "linear" }, {} as any);
1384
+
1385
+ // All linear tools now available
1386
+ expect(toolNames()).toContain("linear/list_issues");
1387
+ expect(toolNames()).toContain("linear/save_issue");
1388
+ expect(toolNames()).toContain("linear/save_comment");
1389
+
1390
+ await harness.shutdown();
1391
+ await new Promise<void>((resolveClose) => mcpServer.close(() => resolveClose()));
1392
+ });
1393
+
1040
1394
  it("supports flat tool access config format", async () => {
1041
1395
  const dir = await mkdtemp(join(tmpdir(), "poncho-harness-flat-tool-access-"));
1042
1396
  await writeFile(
@@ -1,5 +1,42 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { TelemetryEmitter } from "../src/telemetry.js";
2
+ import { TelemetryEmitter, normalizeOtlp } from "../src/telemetry.js";
3
+
4
+ describe("normalizeOtlp", () => {
5
+ it("appends /v1/traces to a base URL string", () => {
6
+ expect(normalizeOtlp("https://gateway.example.com/api/v1/otlp")).toEqual({
7
+ url: "https://gateway.example.com/api/v1/otlp/v1/traces",
8
+ });
9
+ });
10
+
11
+ it("does not double-append when URL already ends with /v1/traces", () => {
12
+ expect(normalizeOtlp("https://api.honeycomb.io/v1/traces")).toEqual({
13
+ url: "https://api.honeycomb.io/v1/traces",
14
+ });
15
+ });
16
+
17
+ it("strips trailing slashes before appending", () => {
18
+ expect(normalizeOtlp("https://gateway.example.com/")).toEqual({
19
+ url: "https://gateway.example.com/v1/traces",
20
+ });
21
+ });
22
+
23
+ it("appends /v1/traces to object config", () => {
24
+ expect(
25
+ normalizeOtlp({
26
+ url: "https://gateway.example.com/otlp",
27
+ headers: { Authorization: "Bearer tok" },
28
+ }),
29
+ ).toEqual({
30
+ url: "https://gateway.example.com/otlp/v1/traces",
31
+ headers: { Authorization: "Bearer tok" },
32
+ });
33
+ });
34
+
35
+ it("returns undefined for falsy input", () => {
36
+ expect(normalizeOtlp(undefined)).toBeUndefined();
37
+ expect(normalizeOtlp("")).toBeUndefined();
38
+ });
39
+ });
3
40
 
4
41
  describe("telemetry emitter", () => {
5
42
  it("delegates to custom handler when configured", async () => {
@@ -14,7 +51,7 @@ describe("telemetry emitter", () => {
14
51
  global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
15
52
 
16
53
  const emitter = new TelemetryEmitter({
17
- otlp: "https://otel.example.com/v1/logs",
54
+ otlp: "https://otel.example.com/v1/traces",
18
55
  });
19
56
 
20
57
  await expect(