@namzu/sdk 3.1.0 → 3.2.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.
- package/CHANGELOG.md +177 -0
- package/dist/advisory/__tests__/consultation-context.test.d.ts +2 -0
- package/dist/advisory/__tests__/consultation-context.test.d.ts.map +1 -0
- package/dist/advisory/__tests__/consultation-context.test.js +124 -0
- package/dist/advisory/__tests__/consultation-context.test.js.map +1 -0
- package/dist/advisory/context.d.ts +25 -0
- package/dist/advisory/context.d.ts.map +1 -1
- package/dist/advisory/context.js +18 -0
- package/dist/advisory/context.js.map +1 -1
- package/dist/advisory/executor.d.ts.map +1 -1
- package/dist/advisory/executor.js +25 -3
- package/dist/advisory/executor.js.map +1 -1
- package/dist/compaction/__tests__/context-reducer.test.d.ts +2 -0
- package/dist/compaction/__tests__/context-reducer.test.d.ts.map +1 -0
- package/dist/compaction/__tests__/context-reducer.test.js +197 -0
- package/dist/compaction/__tests__/context-reducer.test.js.map +1 -0
- package/dist/compaction/factory.d.ts +7 -0
- package/dist/compaction/factory.d.ts.map +1 -1
- package/dist/compaction/factory.js +7 -0
- package/dist/compaction/factory.js.map +1 -1
- package/dist/compaction/index.d.ts +2 -0
- package/dist/compaction/index.d.ts.map +1 -1
- package/dist/compaction/index.js +1 -0
- package/dist/compaction/index.js.map +1 -1
- package/dist/compaction/interface.d.ts +13 -0
- package/dist/compaction/interface.d.ts.map +1 -1
- package/dist/compaction/managers/null.d.ts +3 -0
- package/dist/compaction/managers/null.d.ts.map +1 -1
- package/dist/compaction/managers/null.js +3 -0
- package/dist/compaction/managers/null.js.map +1 -1
- package/dist/compaction/managers/slidingWindow.d.ts +6 -0
- package/dist/compaction/managers/slidingWindow.d.ts.map +1 -1
- package/dist/compaction/managers/slidingWindow.js +6 -0
- package/dist/compaction/managers/slidingWindow.js.map +1 -1
- package/dist/compaction/managers/structured.d.ts +10 -0
- package/dist/compaction/managers/structured.d.ts.map +1 -1
- package/dist/compaction/managers/structured.js +10 -0
- package/dist/compaction/managers/structured.js.map +1 -1
- package/dist/compaction/reducer.d.ts +86 -0
- package/dist/compaction/reducer.d.ts.map +1 -0
- package/dist/compaction/reducer.js +77 -0
- package/dist/compaction/reducer.js.map +1 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts +2 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts.map +1 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.js +54 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.js.map +1 -0
- package/dist/connector/builtins/http.d.ts.map +1 -1
- package/dist/connector/builtins/http.js +24 -2
- package/dist/connector/builtins/http.js.map +1 -1
- package/dist/connector/builtins/http.test.js +18 -2
- package/dist/connector/builtins/http.test.js.map +1 -1
- package/dist/connector/index.d.ts +2 -2
- package/dist/connector/index.d.ts.map +1 -1
- package/dist/connector/index.js +1 -1
- package/dist/connector/index.js.map +1 -1
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js +214 -0
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js.map +1 -0
- package/dist/connector/mcp/client.d.ts +52 -1
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +86 -0
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/connector/mcp/discovery.d.ts +12 -1
- package/dist/connector/mcp/discovery.d.ts.map +1 -1
- package/dist/connector/mcp/discovery.js +19 -4
- package/dist/connector/mcp/discovery.js.map +1 -1
- package/dist/connector/mcp/index.d.ts +2 -2
- package/dist/connector/mcp/index.d.ts.map +1 -1
- package/dist/connector/mcp/index.js +1 -1
- package/dist/connector/mcp/index.js.map +1 -1
- package/dist/connector/mcp/server.d.ts +42 -1
- package/dist/connector/mcp/server.d.ts.map +1 -1
- package/dist/connector/mcp/server.js +77 -4
- package/dist/connector/mcp/server.js.map +1 -1
- package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts +2 -0
- package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts.map +1 -0
- package/dist/manager/agent/__tests__/depth-limit-authority.test.js +58 -0
- package/dist/manager/agent/__tests__/depth-limit-authority.test.js.map +1 -0
- package/dist/plugin/__tests__/discovery-scopes.test.d.ts +2 -0
- package/dist/plugin/__tests__/discovery-scopes.test.d.ts.map +1 -0
- package/dist/plugin/__tests__/discovery-scopes.test.js +97 -0
- package/dist/plugin/__tests__/discovery-scopes.test.js.map +1 -0
- package/dist/plugin/__tests__/enable-contributions.test.js +5 -1
- package/dist/plugin/__tests__/enable-contributions.test.js.map +1 -1
- package/dist/plugin/__tests__/mcp-admission.test.d.ts +2 -0
- package/dist/plugin/__tests__/mcp-admission.test.d.ts.map +1 -0
- package/dist/plugin/__tests__/mcp-admission.test.js +192 -0
- package/dist/plugin/__tests__/mcp-admission.test.js.map +1 -0
- package/dist/plugin/lifecycle.d.ts +41 -0
- package/dist/plugin/lifecycle.d.ts.map +1 -1
- package/dist/plugin/lifecycle.js +29 -1
- package/dist/plugin/lifecycle.js.map +1 -1
- package/dist/plugin/loader.d.ts +39 -3
- package/dist/plugin/loader.d.ts.map +1 -1
- package/dist/plugin/loader.js +37 -4
- package/dist/plugin/loader.js.map +1 -1
- package/dist/public-runtime.d.ts +4 -2
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +5 -2
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +2 -2
- package/dist/public-types.d.ts.map +1 -1
- package/dist/rag/__tests__/namespace-isolation.test.d.ts +2 -0
- package/dist/rag/__tests__/namespace-isolation.test.d.ts.map +1 -0
- package/dist/rag/__tests__/namespace-isolation.test.js +80 -0
- package/dist/rag/__tests__/namespace-isolation.test.js.map +1 -0
- package/dist/rag/ingestion.d.ts.map +1 -1
- package/dist/rag/ingestion.js +1 -0
- package/dist/rag/ingestion.js.map +1 -1
- package/dist/rag/retriever.d.ts.map +1 -1
- package/dist/rag/retriever.js +2 -0
- package/dist/rag/retriever.js.map +1 -1
- package/dist/rag/vector-store.d.ts.map +1 -1
- package/dist/rag/vector-store.js +6 -0
- package/dist/rag/vector-store.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +113 -109
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/runtime/query/__tests__/per-step-skills.test.d.ts +10 -0
- package/dist/runtime/query/__tests__/per-step-skills.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/per-step-skills.test.js +122 -0
- package/dist/runtime/query/__tests__/per-step-skills.test.js.map +1 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.js +153 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.js.map +1 -0
- package/dist/runtime/query/__tests__/resume-run.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/resume-run.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/resume-run.test.js +211 -0
- package/dist/runtime/query/__tests__/resume-run.test.js.map +1 -0
- package/dist/runtime/query/index.d.ts +10 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +26 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +96 -34
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js +96 -0
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +95 -5
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js +180 -0
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/resume-run.d.ts +70 -0
- package/dist/runtime/query/resume-run.d.ts.map +1 -0
- package/dist/runtime/query/resume-run.js +46 -0
- package/dist/runtime/query/resume-run.js.map +1 -0
- package/dist/telemetry/__tests__/model-call-span.test.d.ts +2 -0
- package/dist/telemetry/__tests__/model-call-span.test.d.ts.map +1 -0
- package/dist/telemetry/__tests__/model-call-span.test.js +147 -0
- package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -0
- package/dist/telemetry/__tests__/span-closure.test.d.ts +2 -0
- package/dist/telemetry/__tests__/span-closure.test.d.ts.map +1 -0
- package/dist/telemetry/__tests__/span-closure.test.js +124 -0
- package/dist/telemetry/__tests__/span-closure.test.js.map +1 -0
- package/dist/tools/advisory/index.js +1 -1
- package/dist/tools/advisory/index.js.map +1 -1
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.js +126 -0
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.js.map +1 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +13 -1
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/coordinator/plan-dependencies.d.ts +43 -0
- package/dist/tools/coordinator/plan-dependencies.d.ts.map +1 -0
- package/dist/tools/coordinator/plan-dependencies.js +148 -0
- package/dist/tools/coordinator/plan-dependencies.js.map +1 -0
- package/dist/types/agent/supervisor.d.ts +15 -0
- package/dist/types/agent/supervisor.d.ts.map +1 -1
- package/dist/types/connector/core.d.ts +34 -0
- package/dist/types/connector/core.d.ts.map +1 -1
- package/dist/types/connector/definition.d.ts +10 -0
- package/dist/types/connector/definition.d.ts.map +1 -1
- package/dist/types/connector/mcp.d.ts +13 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/rag/retrieval.d.ts +16 -0
- package/dist/types/rag/retrieval.d.ts.map +1 -1
- package/dist/types/rag/storage.d.ts +9 -0
- package/dist/types/rag/storage.d.ts.map +1 -1
- package/dist/types/rag/vector.d.ts +11 -0
- package/dist/types/rag/vector.d.ts.map +1 -1
- package/dist/types/router/task-router.d.ts +19 -0
- package/dist/types/router/task-router.d.ts.map +1 -1
- package/dist/types/run/prepare-step.d.ts +56 -3
- package/dist/types/run/prepare-step.d.ts.map +1 -1
- package/dist/types/toolset/index.d.ts +22 -0
- package/dist/types/toolset/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/advisory/__tests__/consultation-context.test.ts +191 -0
- package/src/advisory/context.ts +32 -0
- package/src/advisory/executor.ts +30 -3
- package/src/compaction/__tests__/context-reducer.test.ts +239 -0
- package/src/compaction/factory.ts +7 -0
- package/src/compaction/index.ts +8 -0
- package/src/compaction/interface.ts +13 -0
- package/src/compaction/managers/null.ts +3 -0
- package/src/compaction/managers/slidingWindow.ts +6 -0
- package/src/compaction/managers/structured.ts +10 -0
- package/src/compaction/reducer.ts +154 -0
- package/src/connector/builtins/__tests__/oauth2-auth.test.ts +73 -0
- package/src/connector/builtins/http.test.ts +28 -2
- package/src/connector/builtins/http.ts +26 -2
- package/src/connector/index.ts +6 -2
- package/src/connector/mcp/__tests__/prompts-and-lifecycle.test.ts +286 -0
- package/src/connector/mcp/client.ts +95 -0
- package/src/connector/mcp/discovery.ts +19 -4
- package/src/connector/mcp/index.ts +6 -2
- package/src/connector/mcp/server.ts +101 -3
- package/src/manager/agent/__tests__/depth-limit-authority.test.ts +74 -0
- package/src/plugin/__tests__/discovery-scopes.test.ts +133 -0
- package/src/plugin/__tests__/enable-contributions.test.ts +5 -1
- package/src/plugin/__tests__/mcp-admission.test.ts +242 -0
- package/src/plugin/lifecycle.ts +57 -1
- package/src/plugin/loader.ts +57 -3
- package/src/public-runtime.ts +8 -0
- package/src/public-types.ts +5 -0
- package/src/rag/__tests__/namespace-isolation.test.ts +109 -0
- package/src/rag/ingestion.ts +1 -0
- package/src/rag/retriever.ts +2 -0
- package/src/rag/vector-store.ts +5 -0
- package/src/registry/tool/execute.ts +123 -119
- package/src/runtime/query/__tests__/per-step-skills.test.ts +154 -0
- package/src/runtime/query/__tests__/per-step-tool-choice.test.ts +180 -0
- package/src/runtime/query/__tests__/resume-run.test.ts +262 -0
- package/src/runtime/query/index.ts +39 -0
- package/src/runtime/query/iteration/index.ts +103 -34
- package/src/runtime/query/iteration/phases/compaction-model-routing.test.ts +125 -0
- package/src/runtime/query/iteration/phases/compaction.ts +106 -5
- package/src/runtime/query/iteration/phases/context-reducer-dispatch.test.ts +238 -0
- package/src/runtime/query/iteration/phases/context.ts +11 -0
- package/src/runtime/query/resume-run.ts +93 -0
- package/src/telemetry/__tests__/model-call-span.test.ts +189 -0
- package/src/telemetry/__tests__/span-closure.test.ts +153 -0
- package/src/tools/advisory/index.ts +1 -1
- package/src/tools/coordinator/__tests__/plan-dependencies.test.ts +186 -0
- package/src/tools/coordinator/index.ts +14 -1
- package/src/tools/coordinator/plan-dependencies.ts +175 -0
- package/src/types/agent/supervisor.ts +15 -0
- package/src/types/connector/core.ts +34 -0
- package/src/types/connector/definition.ts +10 -0
- package/src/types/connector/mcp.ts +14 -0
- package/src/types/rag/retrieval.ts +16 -0
- package/src/types/rag/storage.ts +9 -0
- package/src/types/rag/vector.ts +11 -0
- package/src/types/router/task-router.ts +19 -0
- package/src/types/run/prepare-step.ts +58 -3
- package/src/types/toolset/index.ts +22 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Message } from '../types/message/index.js'
|
|
2
|
+
import { findSafeTrimIndex } from './dangling.js'
|
|
3
|
+
import { findRetainedIndices } from './retention.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Why the runtime is asking for a shorter history.
|
|
7
|
+
*
|
|
8
|
+
* The two cases want different behaviour and a reducer that cannot tell
|
|
9
|
+
* them apart has to guess. `'threshold'` is speculative — the estimate says
|
|
10
|
+
* the window is filling, nothing has failed, and declining costs only some
|
|
11
|
+
* headroom. `'overflow'` is the provider having already rejected the
|
|
12
|
+
* prompt: declining there ends the run.
|
|
13
|
+
*/
|
|
14
|
+
export type ContextReductionReason = 'threshold' | 'overflow'
|
|
15
|
+
|
|
16
|
+
/** Everything a reducer needs to decide, and nothing it cannot act on. */
|
|
17
|
+
export interface ContextReduction {
|
|
18
|
+
/** The live history, oldest first. Treat as read-only and return a new array. */
|
|
19
|
+
readonly messages: readonly Message[]
|
|
20
|
+
readonly reason: ContextReductionReason
|
|
21
|
+
/** Estimated size of `messages`. Derived from the provider's last prompt count when there is one, else measured. */
|
|
22
|
+
readonly estimatedTokens: number
|
|
23
|
+
/** What it has to fit in. Resolved from the model when the host did not say. */
|
|
24
|
+
readonly contextWindowTokens: number
|
|
25
|
+
/** The model this run is calling, for a reducer that varies by model. */
|
|
26
|
+
readonly model: string
|
|
27
|
+
/** The run's configured recent-window size, as a starting point. */
|
|
28
|
+
readonly keepRecentMessages: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Replace the run's history with a shorter one.
|
|
33
|
+
*
|
|
34
|
+
* Returning `undefined` means "I could not shorten this" and is a first-class
|
|
35
|
+
* answer, not a failure — on `'threshold'` the run simply continues, and on
|
|
36
|
+
* `'overflow'` it fails with an irreducible-prompt error instead of retrying
|
|
37
|
+
* a prompt nothing changed. That is deliberate: a reducer that returned the
|
|
38
|
+
* input unchanged while reporting success would send the same rejected
|
|
39
|
+
* prompt again and burn a call to learn the same thing.
|
|
40
|
+
*
|
|
41
|
+
* A reducer OWNS reduction for the run it governs. The built-in structured
|
|
42
|
+
* pass — LLM-verified summarization, stale tool-result clearing, working
|
|
43
|
+
* state slots — does not also run, because two mechanisms editing the same
|
|
44
|
+
* history in one pass cannot both be reasoned about.
|
|
45
|
+
*
|
|
46
|
+
* Invariants a reducer is expected to keep, all three enforced by the
|
|
47
|
+
* built-in one and none of them checkable from here:
|
|
48
|
+
*
|
|
49
|
+
* 1. The leading system messages stay. They carry the system prompt and the
|
|
50
|
+
* working-memory slot; dropping them changes who the agent is.
|
|
51
|
+
* 2. `tool_use` and its `tool_result` stay together. A split pair is a 400
|
|
52
|
+
* from the provider, so a reducer that splits one turns a context problem
|
|
53
|
+
* into a dead run. {@link findSafeTrimIndex} is exported for this.
|
|
54
|
+
* 3. Messages marked `retain` survive. That marker is how a caller says a
|
|
55
|
+
* fact in the middle of the conversation outranks recency.
|
|
56
|
+
*/
|
|
57
|
+
export type ContextReducer = (
|
|
58
|
+
reduction: ContextReduction,
|
|
59
|
+
) => readonly Message[] | undefined | Promise<readonly Message[] | undefined>
|
|
60
|
+
|
|
61
|
+
export interface SlidingWindowOptions {
|
|
62
|
+
/**
|
|
63
|
+
* How many trailing messages to keep. Defaults to the run's configured
|
|
64
|
+
* `keepRecentMessages`, so the same knob governs both strategies.
|
|
65
|
+
*/
|
|
66
|
+
readonly keepRecentMessages?: number
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fraction of the window to keep when relieving an overflow, applied to
|
|
70
|
+
* the recent count. Defaults to 0.5.
|
|
71
|
+
*
|
|
72
|
+
* An overflow means the ordinary window was already too big, so cutting
|
|
73
|
+
* to the same size again would shed nothing and the caller would retry
|
|
74
|
+
* an identical prompt.
|
|
75
|
+
*/
|
|
76
|
+
readonly overflowFactor?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Keep the last N turns, drop what precedes them, summarize nothing.
|
|
81
|
+
*
|
|
82
|
+
* The honest counterpart to the structured strategy rather than a lesser
|
|
83
|
+
* version of it. Structured compaction pays a summarization call and
|
|
84
|
+
* paraphrases the agent's own reasoning to buy context; this pays nothing
|
|
85
|
+
* and admits the older history is gone. For a long-running agent whose
|
|
86
|
+
* state lives outside the transcript — a task queue, a file it keeps
|
|
87
|
+
* editing, a working-memory block the host renders each turn — that is the
|
|
88
|
+
* better trade, and the paraphrase was only ever cost.
|
|
89
|
+
*
|
|
90
|
+
* Until now `strategy: 'sliding-window'` was accepted by the config schema
|
|
91
|
+
* and then ignored: the runtime asked only whether the strategy was
|
|
92
|
+
* `'disabled'`, so choosing the cheap non-LLM path silently ran the
|
|
93
|
+
* expensive LLM one. This is the implementation that name always claimed.
|
|
94
|
+
*/
|
|
95
|
+
export function createSlidingWindowReducer(options: SlidingWindowOptions = {}): ContextReducer {
|
|
96
|
+
const factor = options.overflowFactor ?? 0.5
|
|
97
|
+
|
|
98
|
+
return ({ messages, reason, keepRecentMessages }) => {
|
|
99
|
+
const configured = options.keepRecentMessages ?? keepRecentMessages
|
|
100
|
+
const keep =
|
|
101
|
+
reason === 'overflow' ? Math.max(1, Math.floor(configured * factor)) : Math.max(1, configured)
|
|
102
|
+
|
|
103
|
+
let leadingSystem = 0
|
|
104
|
+
while (messages[leadingSystem]?.role === 'system') leadingSystem++
|
|
105
|
+
|
|
106
|
+
const naive = messages.length - keep
|
|
107
|
+
if (naive <= leadingSystem) return undefined
|
|
108
|
+
|
|
109
|
+
// `findSafeTrimIndex` moves a cut forward until the kept tail is a
|
|
110
|
+
// history a provider will accept — no `tool_result` without its
|
|
111
|
+
// `tool_use`, and never opening on an assistant turn, because the tail
|
|
112
|
+
// becomes the start of the wire conversation once the older half is
|
|
113
|
+
// gone. So a candidate is safe exactly when it is already its own
|
|
114
|
+
// answer.
|
|
115
|
+
//
|
|
116
|
+
// Walk BACKWARDS first: that lands on the safe cut nearest the
|
|
117
|
+
// requested window, keeping a little more than asked rather than a lot
|
|
118
|
+
// less.
|
|
119
|
+
const trimmable = [...messages]
|
|
120
|
+
let cut = -1
|
|
121
|
+
for (let candidate = naive; candidate > leadingSystem; candidate--) {
|
|
122
|
+
if (findSafeTrimIndex(trimmable, candidate) === candidate) {
|
|
123
|
+
cut = candidate
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Nothing safe below the request. Take the answer ABOVE it instead of
|
|
129
|
+
// refusing: in a multi-step turn — the agent working through tool calls
|
|
130
|
+
// with the user silent — every boundary in the recent window lands on
|
|
131
|
+
// an assistant or tool message, so a backwards-only search declines
|
|
132
|
+
// exactly when the history is longest and shedding matters most. The
|
|
133
|
+
// forward cut keeps less than asked, which is a smaller history than
|
|
134
|
+
// the caller wanted but still a correct one.
|
|
135
|
+
if (cut < 0) {
|
|
136
|
+
const forward = findSafeTrimIndex(trimmable, naive)
|
|
137
|
+
// `messages.length` means "keep nothing", which is not a history.
|
|
138
|
+
if (forward > leadingSystem && forward < messages.length) cut = forward
|
|
139
|
+
}
|
|
140
|
+
if (cut < 0) return undefined
|
|
141
|
+
|
|
142
|
+
const retained = findRetainedIndices(messages)
|
|
143
|
+
const survivors: Message[] = []
|
|
144
|
+
for (let i = 0; i < messages.length; i++) {
|
|
145
|
+
const message = messages[i]
|
|
146
|
+
if (!message) continue
|
|
147
|
+
if (i < leadingSystem || i >= cut || retained.has(i)) survivors.push(message)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Nothing went. Reporting that as a reduction would have the overflow
|
|
151
|
+
// path retry an identical prompt.
|
|
152
|
+
return survivors.length === messages.length ? undefined : survivors
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { AuthConfig } from '../../../types/connector/index.js'
|
|
4
|
+
import { HttpConnector } from '../http.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `'oauth2'` was grouped with `'none'` and `'custom'`, returning no headers.
|
|
8
|
+
*
|
|
9
|
+
* So a connector configured for OAuth2 sent an UNAUTHENTICATED request.
|
|
10
|
+
* Every other auth type throws on a missing credential; this one quietly
|
|
11
|
+
* did not, and the upstream's 401 reads as a bad token rather than as no
|
|
12
|
+
* token at all — which sends whoever is debugging to look at the token.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function headersFor(auth: AuthConfig): Record<string, string> {
|
|
16
|
+
const connector = new HttpConnector()
|
|
17
|
+
// The resolver is private by design; reaching it directly is what keeps
|
|
18
|
+
// this test about the header decision rather than about fetch.
|
|
19
|
+
return (
|
|
20
|
+
connector as unknown as { resolveAuthHeaders(a: AuthConfig): Record<string, string> }
|
|
21
|
+
).resolveAuthHeaders(auth)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('an oauth2 connector does not send an unauthenticated request', () => {
|
|
25
|
+
it('sends the access token it was given', () => {
|
|
26
|
+
const headers = headersFor({
|
|
27
|
+
type: 'oauth2',
|
|
28
|
+
credentials: { accessToken: 'tok_abc' },
|
|
29
|
+
} as AuthConfig)
|
|
30
|
+
|
|
31
|
+
expect(headers.Authorization).toBe('Bearer tok_abc')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('accepts the token under either name', () => {
|
|
35
|
+
const headers = headersFor({ type: 'oauth2', credentials: { token: 'tok_xyz' } } as AuthConfig)
|
|
36
|
+
|
|
37
|
+
expect(headers.Authorization).toBe('Bearer tok_xyz')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('refuses when there is no token, rather than sending nothing', () => {
|
|
41
|
+
// The whole defect: an empty header set went out and the request
|
|
42
|
+
// reached the upstream with no credential at all.
|
|
43
|
+
expect(() => headersFor({ type: 'oauth2', credentials: {} } as AuthConfig)).toThrow(
|
|
44
|
+
/accessToken/,
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('refuses when credentials are absent entirely', () => {
|
|
49
|
+
expect(() => headersFor({ type: 'oauth2' } as AuthConfig)).toThrow(/unauthenticated/)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('the other schemes are unchanged', () => {
|
|
54
|
+
it("still sends nothing for 'none'", () => {
|
|
55
|
+
expect(headersFor({ type: 'none' } as AuthConfig)).toEqual({})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("still sends nothing for 'custom', which is the host's job", () => {
|
|
59
|
+
// Deliberately empty, unlike oauth2: `custom` means the host attaches
|
|
60
|
+
// its own headers, so there is nothing to omit and nothing to refuse.
|
|
61
|
+
expect(headersFor({ type: 'custom' } as AuthConfig)).toEqual({})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('still sends a bearer token', () => {
|
|
65
|
+
expect(
|
|
66
|
+
headersFor({ type: 'bearer', credentials: { token: 't' } } as AuthConfig).Authorization,
|
|
67
|
+
).toBe('Bearer t')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('still refuses a bearer with no token', () => {
|
|
71
|
+
expect(() => headersFor({ type: 'bearer', credentials: {} } as AuthConfig)).toThrow(/token/)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -140,8 +140,12 @@ describe('HttpConnector', () => {
|
|
|
140
140
|
expect(headers.Authorization).toBe(`Basic ${btoa('alice:pw')}`)
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
-
it('none /
|
|
144
|
-
|
|
143
|
+
it('none / custom add no auth headers', async () => {
|
|
144
|
+
// `oauth2` was in this list, and that is what the defect was: it
|
|
145
|
+
// meant a connector configured for OAuth2 reached the upstream with
|
|
146
|
+
// no credential at all. `none` and `custom` belong here — the first
|
|
147
|
+
// asks for no auth, the second says the host attaches its own.
|
|
148
|
+
for (const type of ['none', 'custom'] as const) {
|
|
145
149
|
const c = new HttpConnector()
|
|
146
150
|
await c.connect({ baseUrl: 'https://api.example.com' }, { type, credentials: {} })
|
|
147
151
|
fetchMock.mockResolvedValueOnce(makeResponse({ status: 200, body: {} }))
|
|
@@ -152,6 +156,28 @@ describe('HttpConnector', () => {
|
|
|
152
156
|
}
|
|
153
157
|
})
|
|
154
158
|
|
|
159
|
+
it('oauth2 refuses rather than reaching the upstream unauthenticated', async () => {
|
|
160
|
+
const c = new HttpConnector()
|
|
161
|
+
|
|
162
|
+
await expect(
|
|
163
|
+
c.connect({ baseUrl: 'https://api.example.com' }, { type: 'oauth2', credentials: {} }),
|
|
164
|
+
).rejects.toThrow(/accessToken/)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('oauth2 sends the access token it was given', async () => {
|
|
168
|
+
const c = new HttpConnector()
|
|
169
|
+
await c.connect(
|
|
170
|
+
{ baseUrl: 'https://api.example.com' },
|
|
171
|
+
{ type: 'oauth2', credentials: { accessToken: 'tok_1' } },
|
|
172
|
+
)
|
|
173
|
+
fetchMock.mockResolvedValueOnce(makeResponse({ status: 200, body: {} }))
|
|
174
|
+
|
|
175
|
+
await c.execute('request', { method: 'GET', path: 'x' })
|
|
176
|
+
|
|
177
|
+
const headers = fetchMock.mock.calls.at(-1)?.[1].headers as Record<string, string>
|
|
178
|
+
expect(headers.Authorization).toBe('Bearer tok_1')
|
|
179
|
+
})
|
|
180
|
+
|
|
155
181
|
it('api_key throws when apiKey is missing', async () => {
|
|
156
182
|
const c = new HttpConnector()
|
|
157
183
|
await expect(
|
|
@@ -188,9 +188,33 @@ export class HttpConnector extends BaseConnector<HttpConnectorConfig> {
|
|
|
188
188
|
const encoded = btoa(`${username}:${password}`)
|
|
189
189
|
return { Authorization: `Basic ${encoded}` }
|
|
190
190
|
}
|
|
191
|
-
case '
|
|
192
|
-
|
|
191
|
+
case 'oauth2': {
|
|
192
|
+
// Grouped with `none` until now, so a connector configured for
|
|
193
|
+
// OAuth2 sent an UNAUTHENTICATED request — every other auth type
|
|
194
|
+
// throws on a missing credential, and this one quietly did not.
|
|
195
|
+
// The upstream answers 401 and the failure reads as a bad token
|
|
196
|
+
// rather than as no token at all.
|
|
197
|
+
//
|
|
198
|
+
// The token exchange itself is not implemented here: a client
|
|
199
|
+
// credentials or authorization-code flow needs a token endpoint,
|
|
200
|
+
// refresh handling and a place to keep the result, none of which
|
|
201
|
+
// belong in a request-header helper. What is supported is an
|
|
202
|
+
// access token the host already holds, which is the case a
|
|
203
|
+
// connector config can actually express today.
|
|
204
|
+
const token = creds.accessToken ?? creds.token
|
|
205
|
+
if (!token) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
'AuthConfig oauth2: missing required credential "accessToken". This connector holds no token endpoint, so an access token obtained elsewhere has to be supplied — sending the request without one would reach the upstream unauthenticated.',
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
return { Authorization: `Bearer ${token}` }
|
|
211
|
+
}
|
|
193
212
|
case 'custom':
|
|
213
|
+
// Deliberately empty, unlike `oauth2` above: `custom` means the
|
|
214
|
+
// host attaches its own headers, so there is nothing here to
|
|
215
|
+
// omit and nothing to refuse.
|
|
216
|
+
return {}
|
|
217
|
+
case 'none':
|
|
194
218
|
return {}
|
|
195
219
|
default: {
|
|
196
220
|
const _exhaustive: never = auth.type
|
package/src/connector/index.ts
CHANGED
|
@@ -51,5 +51,9 @@ export { MCPToolDiscovery } from './mcp/discovery.js'
|
|
|
51
51
|
|
|
52
52
|
export { MCPConnectorBridge } from '../bridge/mcp/connector/adapter.js'
|
|
53
53
|
|
|
54
|
-
export { MCPServer } from './mcp/server.js'
|
|
55
|
-
export type {
|
|
54
|
+
export { MCPServer, MCPMethodNotFound } from './mcp/server.js'
|
|
55
|
+
export type {
|
|
56
|
+
MCPServerToolProvider,
|
|
57
|
+
MCPServerResourceProvider,
|
|
58
|
+
MCPServerPromptProvider,
|
|
59
|
+
} from './mcp/server.js'
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { JSON_RPC_METHOD_NOT_FOUND } from '../../../constants/mcp/index.js'
|
|
4
|
+
import type {
|
|
5
|
+
MCPJsonRpcMessage,
|
|
6
|
+
MCPPromptDefinition,
|
|
7
|
+
MCPPromptMessage,
|
|
8
|
+
MCPTransport,
|
|
9
|
+
} from '../../../types/connector/index.js'
|
|
10
|
+
import { MCPClient } from '../client.js'
|
|
11
|
+
import { MCPServer } from '../server.js'
|
|
12
|
+
import type { MCPServerPromptProvider, MCPServerToolProvider } from '../server.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The half of MCP that is not tools.
|
|
16
|
+
*
|
|
17
|
+
* `MCPPromptDefinition` and `MCPPromptArgument` were declared when the types
|
|
18
|
+
* were written; no client method ever asked for a prompt and no server branch
|
|
19
|
+
* ever served one. `MCPLifecycleEvent` and `MCPEventListener` were declared
|
|
20
|
+
* beside them and nothing ever emitted one, so a host could learn that a
|
|
21
|
+
* server had died only by noticing calls had started failing.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function fakeTransport(): {
|
|
25
|
+
transport: MCPTransport
|
|
26
|
+
sent: MCPJsonRpcMessage[]
|
|
27
|
+
deliver: (m: MCPJsonRpcMessage) => void
|
|
28
|
+
} {
|
|
29
|
+
const sent: MCPJsonRpcMessage[] = []
|
|
30
|
+
let onMessage: ((m: MCPJsonRpcMessage) => void) | undefined
|
|
31
|
+
const transport: MCPTransport = {
|
|
32
|
+
connect: async () => undefined,
|
|
33
|
+
close: async () => undefined,
|
|
34
|
+
send: async (m: MCPJsonRpcMessage) => {
|
|
35
|
+
sent.push(m)
|
|
36
|
+
},
|
|
37
|
+
onMessage: (h: (m: MCPJsonRpcMessage) => void) => {
|
|
38
|
+
onMessage = h
|
|
39
|
+
},
|
|
40
|
+
onClose: () => undefined,
|
|
41
|
+
onError: () => undefined,
|
|
42
|
+
} as unknown as MCPTransport
|
|
43
|
+
return { transport, sent, deliver: (m) => onMessage?.(m) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const toolProvider: MCPServerToolProvider = {
|
|
47
|
+
listTools: () => [],
|
|
48
|
+
callTool: async () => ({ content: [{ type: 'text', text: 'ok' }] }),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function promptProvider(over: Partial<MCPServerPromptProvider> = {}): MCPServerPromptProvider {
|
|
52
|
+
const prompts: MCPPromptDefinition[] = [
|
|
53
|
+
{
|
|
54
|
+
name: 'summarize',
|
|
55
|
+
description: 'Summarize a file',
|
|
56
|
+
arguments: [
|
|
57
|
+
{ name: 'path', required: true },
|
|
58
|
+
{ name: 'style', required: false },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
const messages: MCPPromptMessage[] = [
|
|
63
|
+
{ role: 'user', content: { type: 'text', text: 'summarize it' } },
|
|
64
|
+
]
|
|
65
|
+
return {
|
|
66
|
+
listPrompts: () => prompts,
|
|
67
|
+
getPrompt: async () => ({ description: 'Summarize a file', messages }),
|
|
68
|
+
...over,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function ask(
|
|
73
|
+
t: ReturnType<typeof fakeTransport>,
|
|
74
|
+
method: string,
|
|
75
|
+
params: Record<string, unknown> = {},
|
|
76
|
+
) {
|
|
77
|
+
t.deliver({ jsonrpc: '2.0', id: 1, method, params } as MCPJsonRpcMessage)
|
|
78
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
79
|
+
return t.sent.at(-1) as MCPJsonRpcMessage & {
|
|
80
|
+
result?: unknown
|
|
81
|
+
error?: { code: number; message: string }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('a server serves the prompts it publishes', () => {
|
|
86
|
+
it('lists them', async () => {
|
|
87
|
+
const t = fakeTransport()
|
|
88
|
+
const server = new MCPServer({ name: 'srv' }, toolProvider, undefined, promptProvider())
|
|
89
|
+
await server.start(t.transport)
|
|
90
|
+
|
|
91
|
+
const reply = await ask(t, 'prompts/list')
|
|
92
|
+
|
|
93
|
+
expect((reply.result as { prompts: MCPPromptDefinition[] }).prompts[0]?.name).toBe('summarize')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('returns the messages the server composed', async () => {
|
|
97
|
+
const t = fakeTransport()
|
|
98
|
+
const server = new MCPServer({ name: 'srv' }, toolProvider, undefined, promptProvider())
|
|
99
|
+
await server.start(t.transport)
|
|
100
|
+
|
|
101
|
+
const reply = await ask(t, 'prompts/get', {
|
|
102
|
+
name: 'summarize',
|
|
103
|
+
arguments: { path: 'a.ts' },
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect((reply.result as { messages: MCPPromptMessage[] }).messages[0]?.content).toEqual({
|
|
107
|
+
type: 'text',
|
|
108
|
+
text: 'summarize it',
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('advertises the capability only when something is behind it', async () => {
|
|
113
|
+
const withPrompts = fakeTransport()
|
|
114
|
+
const without = fakeTransport()
|
|
115
|
+
const a = new MCPServer({ name: 'a' }, toolProvider, undefined, promptProvider())
|
|
116
|
+
const b = new MCPServer({ name: 'b' }, toolProvider)
|
|
117
|
+
await a.start(withPrompts.transport)
|
|
118
|
+
await b.start(without.transport)
|
|
119
|
+
|
|
120
|
+
const withCap = await ask(withPrompts, 'initialize')
|
|
121
|
+
const withoutCap = await ask(without, 'initialize')
|
|
122
|
+
|
|
123
|
+
// A capability advertised without a provider is a promise the next
|
|
124
|
+
// call breaks.
|
|
125
|
+
expect(
|
|
126
|
+
(withCap.result as { capabilities: Record<string, unknown> }).capabilities.prompts,
|
|
127
|
+
).toBeDefined()
|
|
128
|
+
expect(
|
|
129
|
+
(withoutCap.result as { capabilities: Record<string, unknown> }).capabilities.prompts,
|
|
130
|
+
).toBeUndefined()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('refuses a required argument the caller omitted', async () => {
|
|
134
|
+
const t = fakeTransport()
|
|
135
|
+
const server = new MCPServer({ name: 'srv' }, toolProvider, undefined, promptProvider())
|
|
136
|
+
await server.start(t.transport)
|
|
137
|
+
|
|
138
|
+
const reply = await ask(t, 'prompts/get', { name: 'summarize', arguments: {} })
|
|
139
|
+
|
|
140
|
+
// Checked from the declaration, so every provider does not have to
|
|
141
|
+
// re-implement it or forget to.
|
|
142
|
+
expect(reply.error?.message).toContain('path')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('accepts an optional argument being absent', async () => {
|
|
146
|
+
const t = fakeTransport()
|
|
147
|
+
const server = new MCPServer({ name: 'srv' }, toolProvider, undefined, promptProvider())
|
|
148
|
+
await server.start(t.transport)
|
|
149
|
+
|
|
150
|
+
const reply = await ask(t, 'prompts/get', {
|
|
151
|
+
name: 'summarize',
|
|
152
|
+
arguments: { path: 'a.ts' },
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
expect(reply.error).toBeUndefined()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('refuses a prompt it does not publish', async () => {
|
|
159
|
+
const t = fakeTransport()
|
|
160
|
+
const server = new MCPServer({ name: 'srv' }, toolProvider, undefined, promptProvider())
|
|
161
|
+
await server.start(t.transport)
|
|
162
|
+
|
|
163
|
+
const reply = await ask(t, 'prompts/get', { name: 'nonexistent' })
|
|
164
|
+
|
|
165
|
+
expect(reply.error?.message).toContain('nonexistent')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('"none" and "not here" are different answers', () => {
|
|
170
|
+
it('says method-not-found for prompts when there is no provider', async () => {
|
|
171
|
+
const t = fakeTransport()
|
|
172
|
+
const server = new MCPServer({ name: 'srv' }, toolProvider)
|
|
173
|
+
await server.start(t.transport)
|
|
174
|
+
|
|
175
|
+
const reply = await ask(t, 'prompts/list')
|
|
176
|
+
|
|
177
|
+
expect(reply.error?.code).toBe(JSON_RPC_METHOD_NOT_FOUND)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('says method-not-found for resources when there is no provider', async () => {
|
|
181
|
+
const t = fakeTransport()
|
|
182
|
+
const server = new MCPServer({ name: 'srv' }, toolProvider)
|
|
183
|
+
await server.start(t.transport)
|
|
184
|
+
|
|
185
|
+
const reply = await ask(t, 'resources/list')
|
|
186
|
+
|
|
187
|
+
// This used to answer `{ resources: [] }` for a capability
|
|
188
|
+
// `initialize` never advertised — telling a client "none" where the
|
|
189
|
+
// truth is "not here". The two send a client in opposite directions.
|
|
190
|
+
expect(reply.error?.code).toBe(JSON_RPC_METHOD_NOT_FOUND)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('uses the protocol code for an unknown method, not a generic failure', async () => {
|
|
194
|
+
const t = fakeTransport()
|
|
195
|
+
const server = new MCPServer({ name: 'srv' }, toolProvider)
|
|
196
|
+
await server.start(t.transport)
|
|
197
|
+
|
|
198
|
+
const reply = await ask(t, 'completely/unknown')
|
|
199
|
+
|
|
200
|
+
expect(reply.error?.code).toBe(JSON_RPC_METHOD_NOT_FOUND)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('still reports a provider failure as an internal error', async () => {
|
|
204
|
+
const t = fakeTransport()
|
|
205
|
+
const server = new MCPServer(
|
|
206
|
+
{ name: 'srv' },
|
|
207
|
+
toolProvider,
|
|
208
|
+
undefined,
|
|
209
|
+
promptProvider({
|
|
210
|
+
listPrompts: () => {
|
|
211
|
+
throw new Error('provider exploded')
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
await server.start(t.transport)
|
|
216
|
+
|
|
217
|
+
const reply = await ask(t, 'prompts/list')
|
|
218
|
+
|
|
219
|
+
// A broken provider is not the same as an unimplemented method, and
|
|
220
|
+
// collapsing the two would tell a client to stop asking for something
|
|
221
|
+
// that works tomorrow.
|
|
222
|
+
expect(reply.error?.code).toBe(-32603)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('a client says out loud what it already knew', () => {
|
|
227
|
+
function failingClient() {
|
|
228
|
+
// `connect` failing is the simplest path that actually emits. A
|
|
229
|
+
// never-connected client's `disconnect` returns early and emits
|
|
230
|
+
// nothing — an easy way to write a test that proves nothing, which is
|
|
231
|
+
// what the first version of these did.
|
|
232
|
+
return new MCPClient({
|
|
233
|
+
serverName: 'srv',
|
|
234
|
+
transport: { type: 'stdio', command: 'definitely-not-a-real-binary-xyz', args: [] },
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Covered here: the transport-error emitter. `connect`'s own catch emits
|
|
240
|
+
* the same event and is NOT pinned — a spawn failure surfaces through the
|
|
241
|
+
* transport first, so the catch is the second line of defence rather than
|
|
242
|
+
* the path this reaches. Reaching it needs a connection that opens and
|
|
243
|
+
* then fails negotiation, which this fake cannot stage; said rather than
|
|
244
|
+
* left for a mutation run to discover.
|
|
245
|
+
*/
|
|
246
|
+
it('reports a failure to whoever is listening', async () => {
|
|
247
|
+
const client = failingClient()
|
|
248
|
+
const seen: string[] = []
|
|
249
|
+
client.onLifecycle((e) => seen.push(e.type))
|
|
250
|
+
|
|
251
|
+
await client.connect().catch(() => undefined)
|
|
252
|
+
|
|
253
|
+
expect(seen).toContain('mcp_client_error')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('stops reporting once the listener unsubscribes', async () => {
|
|
257
|
+
const client = failingClient()
|
|
258
|
+
const seen = vi.fn()
|
|
259
|
+
|
|
260
|
+
const off = client.onLifecycle(seen)
|
|
261
|
+
off()
|
|
262
|
+
await client.connect().catch(() => undefined)
|
|
263
|
+
|
|
264
|
+
// `onNotification` returns nothing, so a listener registered there
|
|
265
|
+
// cannot be removed and keeps a disposed host object alive for as
|
|
266
|
+
// long as the client. This is that bug not repeated.
|
|
267
|
+
expect(seen).not.toHaveBeenCalled()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('leaves other listeners working when one throws', async () => {
|
|
271
|
+
const client = failingClient()
|
|
272
|
+
const survivor = vi.fn()
|
|
273
|
+
client.onLifecycle(() => {
|
|
274
|
+
throw new Error('observer bug')
|
|
275
|
+
})
|
|
276
|
+
client.onLifecycle(survivor)
|
|
277
|
+
|
|
278
|
+
await client.connect().catch(() => undefined)
|
|
279
|
+
|
|
280
|
+
// These fire from inside transport callbacks and from `connect`'s
|
|
281
|
+
// failure path, so an escaping exception would surface as a connection
|
|
282
|
+
// error — blaming the server for a bug in the host's own observer, and
|
|
283
|
+
// silencing every listener registered after the broken one.
|
|
284
|
+
expect(survivor).toHaveBeenCalled()
|
|
285
|
+
})
|
|
286
|
+
})
|