@poncho-ai/harness 0.33.1 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +17 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +50 -13
- package/package.json +1 -1
- package/src/compaction.ts +10 -2
- package/src/harness.ts +63 -11
- package/test/harness.test.ts +354 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/harness@0.
|
|
2
|
+
> @poncho-ai/harness@0.34.1 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
|
[34mCLI[39m tsup v8.5.1
|
|
9
9
|
[34mCLI[39m Target: es2022
|
|
10
10
|
[34mESM[39m Build start
|
|
11
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
12
|
-
[32mESM[39m ⚡️ Build success in
|
|
11
|
+
[32mESM[39m [1mdist/index.js [22m[32m336.65 KB[39m
|
|
12
|
+
[32mESM[39m ⚡️ Build success in 155ms
|
|
13
13
|
[34mDTS[39m Build start
|
|
14
|
-
[32mDTS[39m ⚡️ Build success in
|
|
15
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[
|
|
14
|
+
[32mDTS[39m ⚡️ Build success in 7364ms
|
|
15
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m34.28 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @poncho-ai/harness
|
|
2
2
|
|
|
3
|
+
## 0.34.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`59a88cc`](https://github.com/cesr/poncho-ai/commit/59a88cc52b5c3aa7432b820424bb8067174233e5) Thanks [@cesr](https://github.com/cesr)! - fix: improve token estimation accuracy and handle missing attachments
|
|
8
|
+
- Use a JSON-specific token ratio for tool definitions to avoid inflating counts with many MCP tools.
|
|
9
|
+
- Track actual context size from model responses for compaction triggers instead of cumulative input tokens.
|
|
10
|
+
- Gracefully degrade when file attachments are missing or expired instead of crashing.
|
|
11
|
+
|
|
12
|
+
## 0.34.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- [`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
|
|
17
|
+
|
|
18
|
+
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`).
|
|
19
|
+
|
|
3
20
|
## 0.33.1
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -83,6 +83,11 @@ declare const resolveCompactionConfig: (explicit?: Partial<CompactionConfig>) =>
|
|
|
83
83
|
declare const estimateTokens: (text: string) => number;
|
|
84
84
|
/**
|
|
85
85
|
* Estimate the total token count of a system prompt + messages + tool defs.
|
|
86
|
+
*
|
|
87
|
+
* Tool definitions are structured JSON (property names, braces, enum values)
|
|
88
|
+
* which tokenizes more efficiently than natural language — roughly 5-6
|
|
89
|
+
* chars/token vs ~4 chars/token for prose. We estimate them separately to
|
|
90
|
+
* avoid inflating the count when there are many MCP tools (100+).
|
|
86
91
|
*/
|
|
87
92
|
declare const estimateTotalTokens: (systemPrompt: string, messages: Message[], toolDefinitionsJson?: string) => number;
|
|
88
93
|
/**
|
|
@@ -759,6 +764,14 @@ declare class AgentHarness {
|
|
|
759
764
|
private getAgentScriptIntent;
|
|
760
765
|
private getAgentMcpApprovalPatterns;
|
|
761
766
|
private getAgentScriptApprovalPatterns;
|
|
767
|
+
/**
|
|
768
|
+
* Return the set of MCP server names that have at least one tool claimed by
|
|
769
|
+
* any loaded skill's `allowedTools.mcp`. When ANY skill claims tools from a
|
|
770
|
+
* server, the entire server is considered "skill-managed" — none of its tools
|
|
771
|
+
* are auto-exposed globally; only explicitly declared tools become available
|
|
772
|
+
* (via agent-level allowed-tools or active skill allowed-tools).
|
|
773
|
+
*/
|
|
774
|
+
private getSkillManagedMcpServers;
|
|
762
775
|
private getRequestedMcpPatterns;
|
|
763
776
|
private getRequestedScriptPatterns;
|
|
764
777
|
private getRequestedMcpApprovalPatterns;
|
package/dist/index.js
CHANGED
|
@@ -397,10 +397,11 @@ var estimateTotalTokens = (systemPrompt, messages, toolDefinitionsJson) => {
|
|
|
397
397
|
return sum + 200;
|
|
398
398
|
}, 0);
|
|
399
399
|
}
|
|
400
|
+
let tokens = Math.ceil(chars / 4 * OVERHEAD_MULTIPLIER);
|
|
400
401
|
if (toolDefinitionsJson) {
|
|
401
|
-
|
|
402
|
+
tokens += Math.ceil(toolDefinitionsJson.length / 6);
|
|
402
403
|
}
|
|
403
|
-
return
|
|
404
|
+
return tokens;
|
|
404
405
|
};
|
|
405
406
|
var findSafeSplitPoint = (messages, keepRecentMessages) => {
|
|
406
407
|
const candidateIdx = messages.length - keepRecentMessages;
|
|
@@ -882,6 +883,7 @@ Connect your Poncho agent to messaging platforms so it responds to @mentions.
|
|
|
882
883
|
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and create a new app "From scratch"
|
|
883
884
|
2. Under **OAuth & Permissions**, add these Bot Token Scopes:
|
|
884
885
|
- \`app_mentions:read\`
|
|
886
|
+
- \`channels:history\` (needed to fetch thread context when mentioned in a reply)
|
|
885
887
|
- \`chat:write\`
|
|
886
888
|
- \`reactions:write\`
|
|
887
889
|
3. Under **Event Subscriptions**, enable events:
|
|
@@ -6301,6 +6303,25 @@ var AgentHarness = class _AgentHarness {
|
|
|
6301
6303
|
getAgentScriptApprovalPatterns() {
|
|
6302
6304
|
return this.parsedAgent?.frontmatter.approvalRequired?.scripts ?? [];
|
|
6303
6305
|
}
|
|
6306
|
+
/**
|
|
6307
|
+
* Return the set of MCP server names that have at least one tool claimed by
|
|
6308
|
+
* any loaded skill's `allowedTools.mcp`. When ANY skill claims tools from a
|
|
6309
|
+
* server, the entire server is considered "skill-managed" — none of its tools
|
|
6310
|
+
* are auto-exposed globally; only explicitly declared tools become available
|
|
6311
|
+
* (via agent-level allowed-tools or active skill allowed-tools).
|
|
6312
|
+
*/
|
|
6313
|
+
getSkillManagedMcpServers() {
|
|
6314
|
+
const servers = /* @__PURE__ */ new Set();
|
|
6315
|
+
for (const skill of this.loadedSkills) {
|
|
6316
|
+
for (const pattern of skill.allowedTools.mcp) {
|
|
6317
|
+
const slash = pattern.indexOf("/");
|
|
6318
|
+
if (slash > 0) {
|
|
6319
|
+
servers.add(pattern.slice(0, slash));
|
|
6320
|
+
}
|
|
6321
|
+
}
|
|
6322
|
+
}
|
|
6323
|
+
return servers;
|
|
6324
|
+
}
|
|
6304
6325
|
getRequestedMcpPatterns() {
|
|
6305
6326
|
const patterns = new Set(this.getAgentMcpIntent());
|
|
6306
6327
|
for (const skillName of this.activeSkillNames) {
|
|
@@ -6312,6 +6333,17 @@ var AgentHarness = class _AgentHarness {
|
|
|
6312
6333
|
patterns.add(pattern);
|
|
6313
6334
|
}
|
|
6314
6335
|
}
|
|
6336
|
+
if (this.mcpBridge) {
|
|
6337
|
+
const managedServers = this.getSkillManagedMcpServers();
|
|
6338
|
+
const discoveredTools = this.mcpBridge.listDiscoveredTools();
|
|
6339
|
+
for (const toolName of discoveredTools) {
|
|
6340
|
+
const slash = toolName.indexOf("/");
|
|
6341
|
+
const serverName = slash > 0 ? toolName.slice(0, slash) : toolName;
|
|
6342
|
+
if (!managedServers.has(serverName)) {
|
|
6343
|
+
patterns.add(toolName);
|
|
6344
|
+
}
|
|
6345
|
+
}
|
|
6346
|
+
}
|
|
6315
6347
|
return [...patterns];
|
|
6316
6348
|
}
|
|
6317
6349
|
getRequestedScriptPatterns() {
|
|
@@ -7258,19 +7290,24 @@ ${textContent}` };
|
|
|
7258
7290
|
};
|
|
7259
7291
|
}
|
|
7260
7292
|
let resolvedData;
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
resolvedData = buf.toString("base64");
|
|
7264
|
-
} else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
|
|
7265
|
-
if (this.uploadStore) {
|
|
7293
|
+
try {
|
|
7294
|
+
if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
|
|
7266
7295
|
const buf = await this.uploadStore.get(part.data);
|
|
7267
7296
|
resolvedData = buf.toString("base64");
|
|
7297
|
+
} else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
|
|
7298
|
+
if (this.uploadStore) {
|
|
7299
|
+
const buf = await this.uploadStore.get(part.data);
|
|
7300
|
+
resolvedData = buf.toString("base64");
|
|
7301
|
+
} else {
|
|
7302
|
+
const resp = await fetch(part.data);
|
|
7303
|
+
resolvedData = Buffer.from(await resp.arrayBuffer()).toString("base64");
|
|
7304
|
+
}
|
|
7268
7305
|
} else {
|
|
7269
|
-
|
|
7270
|
-
resolvedData = Buffer.from(await resp.arrayBuffer()).toString("base64");
|
|
7306
|
+
resolvedData = part.data;
|
|
7271
7307
|
}
|
|
7272
|
-
}
|
|
7273
|
-
|
|
7308
|
+
} catch {
|
|
7309
|
+
const label = part.filename ?? part.mediaType;
|
|
7310
|
+
return { type: "text", text: `[Attached file: ${label} \u2014 file is no longer available]` };
|
|
7274
7311
|
}
|
|
7275
7312
|
if (isSupportedImage) {
|
|
7276
7313
|
return {
|
|
@@ -7299,8 +7336,8 @@ ${textContent}` };
|
|
|
7299
7336
|
const compactionConfig = resolveCompactionConfig(agent.frontmatter.compaction);
|
|
7300
7337
|
if (compactionConfig.enabled && (step === 1 || step % COMPACTION_CHECK_INTERVAL_STEPS === 0)) {
|
|
7301
7338
|
const estimated = estimateTotalTokens(systemPrompt, messages, toolDefsJsonForEstimate);
|
|
7302
|
-
const
|
|
7303
|
-
const effectiveTokens = Math.max(estimated,
|
|
7339
|
+
const lastReportedContext = latestContextTokens > 0 ? latestContextTokens + toolOutputEstimateSinceModel : 0;
|
|
7340
|
+
const effectiveTokens = Math.max(estimated, lastReportedContext);
|
|
7304
7341
|
if (effectiveTokens > compactionConfig.trigger * contextWindow) {
|
|
7305
7342
|
yield pushEvent({ type: "compaction:started", estimatedTokens: effectiveTokens });
|
|
7306
7343
|
const compactResult = await compactMessages(
|
package/package.json
CHANGED
package/src/compaction.ts
CHANGED
|
@@ -49,6 +49,11 @@ export const estimateTokens = (text: string): number =>
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Estimate the total token count of a system prompt + messages + tool defs.
|
|
52
|
+
*
|
|
53
|
+
* Tool definitions are structured JSON (property names, braces, enum values)
|
|
54
|
+
* which tokenizes more efficiently than natural language — roughly 5-6
|
|
55
|
+
* chars/token vs ~4 chars/token for prose. We estimate them separately to
|
|
56
|
+
* avoid inflating the count when there are many MCP tools (100+).
|
|
52
57
|
*/
|
|
53
58
|
export const estimateTotalTokens = (
|
|
54
59
|
systemPrompt: string,
|
|
@@ -64,10 +69,13 @@ export const estimateTotalTokens = (
|
|
|
64
69
|
return sum + 200; // rough estimate for file/image references
|
|
65
70
|
}, 0);
|
|
66
71
|
}
|
|
72
|
+
let tokens = Math.ceil((chars / 4) * OVERHEAD_MULTIPLIER);
|
|
67
73
|
if (toolDefinitionsJson) {
|
|
68
|
-
|
|
74
|
+
// JSON-specific ratio — no overhead multiplier (structural tokens are
|
|
75
|
+
// already accounted for by the higher chars-per-token ratio).
|
|
76
|
+
tokens += Math.ceil(toolDefinitionsJson.length / 6);
|
|
69
77
|
}
|
|
70
|
-
return
|
|
78
|
+
return tokens;
|
|
71
79
|
};
|
|
72
80
|
|
|
73
81
|
/**
|
package/src/harness.ts
CHANGED
|
@@ -995,8 +995,30 @@ export class AgentHarness {
|
|
|
995
995
|
return this.parsedAgent?.frontmatter.approvalRequired?.scripts ?? [];
|
|
996
996
|
}
|
|
997
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
|
+
|
|
998
1018
|
private getRequestedMcpPatterns(): string[] {
|
|
999
1019
|
const patterns = new Set<string>(this.getAgentMcpIntent());
|
|
1020
|
+
|
|
1021
|
+
// Add patterns from active skills.
|
|
1000
1022
|
for (const skillName of this.activeSkillNames) {
|
|
1001
1023
|
const skill = this.loadedSkills.find((entry) => entry.name === skillName);
|
|
1002
1024
|
if (!skill) {
|
|
@@ -1006,6 +1028,26 @@ export class AgentHarness {
|
|
|
1006
1028
|
patterns.add(pattern);
|
|
1007
1029
|
}
|
|
1008
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
|
+
|
|
1009
1051
|
return [...patterns];
|
|
1010
1052
|
}
|
|
1011
1053
|
|
|
@@ -2112,19 +2154,24 @@ ${boundedMainMemory.trim()}`
|
|
|
2112
2154
|
// Always resolve to base64 so the model doesn't need to
|
|
2113
2155
|
// fetch URLs itself (which fails for private blob stores).
|
|
2114
2156
|
let resolvedData: string;
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
resolvedData = buf.toString("base64");
|
|
2118
|
-
} else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
|
|
2119
|
-
if (this.uploadStore) {
|
|
2157
|
+
try {
|
|
2158
|
+
if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
|
|
2120
2159
|
const buf = await this.uploadStore.get(part.data);
|
|
2121
2160
|
resolvedData = buf.toString("base64");
|
|
2161
|
+
} else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
|
|
2162
|
+
if (this.uploadStore) {
|
|
2163
|
+
const buf = await this.uploadStore.get(part.data);
|
|
2164
|
+
resolvedData = buf.toString("base64");
|
|
2165
|
+
} else {
|
|
2166
|
+
const resp = await fetch(part.data);
|
|
2167
|
+
resolvedData = Buffer.from(await resp.arrayBuffer()).toString("base64");
|
|
2168
|
+
}
|
|
2122
2169
|
} else {
|
|
2123
|
-
|
|
2124
|
-
resolvedData = Buffer.from(await resp.arrayBuffer()).toString("base64");
|
|
2170
|
+
resolvedData = part.data;
|
|
2125
2171
|
}
|
|
2126
|
-
}
|
|
2127
|
-
|
|
2172
|
+
} catch {
|
|
2173
|
+
const label = part.filename ?? part.mediaType;
|
|
2174
|
+
return { type: "text" as const, text: `[Attached file: ${label} — file is no longer available]` };
|
|
2128
2175
|
}
|
|
2129
2176
|
if (isSupportedImage) {
|
|
2130
2177
|
return {
|
|
@@ -2158,8 +2205,13 @@ ${boundedMainMemory.trim()}`
|
|
|
2158
2205
|
const compactionConfig = resolveCompactionConfig(agent.frontmatter.compaction);
|
|
2159
2206
|
if (compactionConfig.enabled && (step === 1 || step % COMPACTION_CHECK_INTERVAL_STEPS === 0)) {
|
|
2160
2207
|
const estimated = estimateTotalTokens(systemPrompt, messages, toolDefsJsonForEstimate);
|
|
2161
|
-
|
|
2162
|
-
|
|
2208
|
+
// Use the actual context size from the last model response (input tokens
|
|
2209
|
+
// + tool output accumulated since), not totalInputTokens which is a
|
|
2210
|
+
// cumulative sum across all steps and would wildly overcount.
|
|
2211
|
+
const lastReportedContext = latestContextTokens > 0
|
|
2212
|
+
? latestContextTokens + toolOutputEstimateSinceModel
|
|
2213
|
+
: 0;
|
|
2214
|
+
const effectiveTokens = Math.max(estimated, lastReportedContext);
|
|
2163
2215
|
|
|
2164
2216
|
if (effectiveTokens > compactionConfig.trigger * contextWindow) {
|
|
2165
2217
|
yield pushEvent({ type: "compaction:started", estimatedTokens: effectiveTokens });
|
package/test/harness.test.ts
CHANGED
|
@@ -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(
|