@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.
Files changed (257) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/dist/advisory/__tests__/consultation-context.test.d.ts +2 -0
  3. package/dist/advisory/__tests__/consultation-context.test.d.ts.map +1 -0
  4. package/dist/advisory/__tests__/consultation-context.test.js +124 -0
  5. package/dist/advisory/__tests__/consultation-context.test.js.map +1 -0
  6. package/dist/advisory/context.d.ts +25 -0
  7. package/dist/advisory/context.d.ts.map +1 -1
  8. package/dist/advisory/context.js +18 -0
  9. package/dist/advisory/context.js.map +1 -1
  10. package/dist/advisory/executor.d.ts.map +1 -1
  11. package/dist/advisory/executor.js +25 -3
  12. package/dist/advisory/executor.js.map +1 -1
  13. package/dist/compaction/__tests__/context-reducer.test.d.ts +2 -0
  14. package/dist/compaction/__tests__/context-reducer.test.d.ts.map +1 -0
  15. package/dist/compaction/__tests__/context-reducer.test.js +197 -0
  16. package/dist/compaction/__tests__/context-reducer.test.js.map +1 -0
  17. package/dist/compaction/factory.d.ts +7 -0
  18. package/dist/compaction/factory.d.ts.map +1 -1
  19. package/dist/compaction/factory.js +7 -0
  20. package/dist/compaction/factory.js.map +1 -1
  21. package/dist/compaction/index.d.ts +2 -0
  22. package/dist/compaction/index.d.ts.map +1 -1
  23. package/dist/compaction/index.js +1 -0
  24. package/dist/compaction/index.js.map +1 -1
  25. package/dist/compaction/interface.d.ts +13 -0
  26. package/dist/compaction/interface.d.ts.map +1 -1
  27. package/dist/compaction/managers/null.d.ts +3 -0
  28. package/dist/compaction/managers/null.d.ts.map +1 -1
  29. package/dist/compaction/managers/null.js +3 -0
  30. package/dist/compaction/managers/null.js.map +1 -1
  31. package/dist/compaction/managers/slidingWindow.d.ts +6 -0
  32. package/dist/compaction/managers/slidingWindow.d.ts.map +1 -1
  33. package/dist/compaction/managers/slidingWindow.js +6 -0
  34. package/dist/compaction/managers/slidingWindow.js.map +1 -1
  35. package/dist/compaction/managers/structured.d.ts +10 -0
  36. package/dist/compaction/managers/structured.d.ts.map +1 -1
  37. package/dist/compaction/managers/structured.js +10 -0
  38. package/dist/compaction/managers/structured.js.map +1 -1
  39. package/dist/compaction/reducer.d.ts +86 -0
  40. package/dist/compaction/reducer.d.ts.map +1 -0
  41. package/dist/compaction/reducer.js +77 -0
  42. package/dist/compaction/reducer.js.map +1 -0
  43. package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts +2 -0
  44. package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts.map +1 -0
  45. package/dist/connector/builtins/__tests__/oauth2-auth.test.js +54 -0
  46. package/dist/connector/builtins/__tests__/oauth2-auth.test.js.map +1 -0
  47. package/dist/connector/builtins/http.d.ts.map +1 -1
  48. package/dist/connector/builtins/http.js +24 -2
  49. package/dist/connector/builtins/http.js.map +1 -1
  50. package/dist/connector/builtins/http.test.js +18 -2
  51. package/dist/connector/builtins/http.test.js.map +1 -1
  52. package/dist/connector/index.d.ts +2 -2
  53. package/dist/connector/index.d.ts.map +1 -1
  54. package/dist/connector/index.js +1 -1
  55. package/dist/connector/index.js.map +1 -1
  56. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts +2 -0
  57. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts.map +1 -0
  58. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js +214 -0
  59. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js.map +1 -0
  60. package/dist/connector/mcp/client.d.ts +52 -1
  61. package/dist/connector/mcp/client.d.ts.map +1 -1
  62. package/dist/connector/mcp/client.js +86 -0
  63. package/dist/connector/mcp/client.js.map +1 -1
  64. package/dist/connector/mcp/discovery.d.ts +12 -1
  65. package/dist/connector/mcp/discovery.d.ts.map +1 -1
  66. package/dist/connector/mcp/discovery.js +19 -4
  67. package/dist/connector/mcp/discovery.js.map +1 -1
  68. package/dist/connector/mcp/index.d.ts +2 -2
  69. package/dist/connector/mcp/index.d.ts.map +1 -1
  70. package/dist/connector/mcp/index.js +1 -1
  71. package/dist/connector/mcp/index.js.map +1 -1
  72. package/dist/connector/mcp/server.d.ts +42 -1
  73. package/dist/connector/mcp/server.d.ts.map +1 -1
  74. package/dist/connector/mcp/server.js +77 -4
  75. package/dist/connector/mcp/server.js.map +1 -1
  76. package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts +2 -0
  77. package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts.map +1 -0
  78. package/dist/manager/agent/__tests__/depth-limit-authority.test.js +58 -0
  79. package/dist/manager/agent/__tests__/depth-limit-authority.test.js.map +1 -0
  80. package/dist/plugin/__tests__/discovery-scopes.test.d.ts +2 -0
  81. package/dist/plugin/__tests__/discovery-scopes.test.d.ts.map +1 -0
  82. package/dist/plugin/__tests__/discovery-scopes.test.js +97 -0
  83. package/dist/plugin/__tests__/discovery-scopes.test.js.map +1 -0
  84. package/dist/plugin/__tests__/enable-contributions.test.js +5 -1
  85. package/dist/plugin/__tests__/enable-contributions.test.js.map +1 -1
  86. package/dist/plugin/__tests__/mcp-admission.test.d.ts +2 -0
  87. package/dist/plugin/__tests__/mcp-admission.test.d.ts.map +1 -0
  88. package/dist/plugin/__tests__/mcp-admission.test.js +192 -0
  89. package/dist/plugin/__tests__/mcp-admission.test.js.map +1 -0
  90. package/dist/plugin/lifecycle.d.ts +41 -0
  91. package/dist/plugin/lifecycle.d.ts.map +1 -1
  92. package/dist/plugin/lifecycle.js +29 -1
  93. package/dist/plugin/lifecycle.js.map +1 -1
  94. package/dist/plugin/loader.d.ts +39 -3
  95. package/dist/plugin/loader.d.ts.map +1 -1
  96. package/dist/plugin/loader.js +37 -4
  97. package/dist/plugin/loader.js.map +1 -1
  98. package/dist/public-runtime.d.ts +4 -2
  99. package/dist/public-runtime.d.ts.map +1 -1
  100. package/dist/public-runtime.js +5 -2
  101. package/dist/public-runtime.js.map +1 -1
  102. package/dist/public-types.d.ts +2 -2
  103. package/dist/public-types.d.ts.map +1 -1
  104. package/dist/rag/__tests__/namespace-isolation.test.d.ts +2 -0
  105. package/dist/rag/__tests__/namespace-isolation.test.d.ts.map +1 -0
  106. package/dist/rag/__tests__/namespace-isolation.test.js +80 -0
  107. package/dist/rag/__tests__/namespace-isolation.test.js.map +1 -0
  108. package/dist/rag/ingestion.d.ts.map +1 -1
  109. package/dist/rag/ingestion.js +1 -0
  110. package/dist/rag/ingestion.js.map +1 -1
  111. package/dist/rag/retriever.d.ts.map +1 -1
  112. package/dist/rag/retriever.js +2 -0
  113. package/dist/rag/retriever.js.map +1 -1
  114. package/dist/rag/vector-store.d.ts.map +1 -1
  115. package/dist/rag/vector-store.js +6 -0
  116. package/dist/rag/vector-store.js.map +1 -1
  117. package/dist/registry/tool/execute.d.ts.map +1 -1
  118. package/dist/registry/tool/execute.js +113 -109
  119. package/dist/registry/tool/execute.js.map +1 -1
  120. package/dist/runtime/query/__tests__/per-step-skills.test.d.ts +10 -0
  121. package/dist/runtime/query/__tests__/per-step-skills.test.d.ts.map +1 -0
  122. package/dist/runtime/query/__tests__/per-step-skills.test.js +122 -0
  123. package/dist/runtime/query/__tests__/per-step-skills.test.js.map +1 -0
  124. package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts +2 -0
  125. package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts.map +1 -0
  126. package/dist/runtime/query/__tests__/per-step-tool-choice.test.js +153 -0
  127. package/dist/runtime/query/__tests__/per-step-tool-choice.test.js.map +1 -0
  128. package/dist/runtime/query/__tests__/resume-run.test.d.ts +2 -0
  129. package/dist/runtime/query/__tests__/resume-run.test.d.ts.map +1 -0
  130. package/dist/runtime/query/__tests__/resume-run.test.js +211 -0
  131. package/dist/runtime/query/__tests__/resume-run.test.js.map +1 -0
  132. package/dist/runtime/query/index.d.ts +10 -0
  133. package/dist/runtime/query/index.d.ts.map +1 -1
  134. package/dist/runtime/query/index.js +26 -0
  135. package/dist/runtime/query/index.js.map +1 -1
  136. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  137. package/dist/runtime/query/iteration/index.js +96 -34
  138. package/dist/runtime/query/iteration/index.js.map +1 -1
  139. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts +2 -0
  140. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts.map +1 -0
  141. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js +96 -0
  142. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js.map +1 -0
  143. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  144. package/dist/runtime/query/iteration/phases/compaction.js +95 -5
  145. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  146. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts +2 -0
  147. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts.map +1 -0
  148. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js +180 -0
  149. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js.map +1 -0
  150. package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
  151. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  152. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  153. package/dist/runtime/query/resume-run.d.ts +70 -0
  154. package/dist/runtime/query/resume-run.d.ts.map +1 -0
  155. package/dist/runtime/query/resume-run.js +46 -0
  156. package/dist/runtime/query/resume-run.js.map +1 -0
  157. package/dist/telemetry/__tests__/model-call-span.test.d.ts +2 -0
  158. package/dist/telemetry/__tests__/model-call-span.test.d.ts.map +1 -0
  159. package/dist/telemetry/__tests__/model-call-span.test.js +147 -0
  160. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -0
  161. package/dist/telemetry/__tests__/span-closure.test.d.ts +2 -0
  162. package/dist/telemetry/__tests__/span-closure.test.d.ts.map +1 -0
  163. package/dist/telemetry/__tests__/span-closure.test.js +124 -0
  164. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -0
  165. package/dist/tools/advisory/index.js +1 -1
  166. package/dist/tools/advisory/index.js.map +1 -1
  167. package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts +2 -0
  168. package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts.map +1 -0
  169. package/dist/tools/coordinator/__tests__/plan-dependencies.test.js +126 -0
  170. package/dist/tools/coordinator/__tests__/plan-dependencies.test.js.map +1 -0
  171. package/dist/tools/coordinator/index.d.ts.map +1 -1
  172. package/dist/tools/coordinator/index.js +13 -1
  173. package/dist/tools/coordinator/index.js.map +1 -1
  174. package/dist/tools/coordinator/plan-dependencies.d.ts +43 -0
  175. package/dist/tools/coordinator/plan-dependencies.d.ts.map +1 -0
  176. package/dist/tools/coordinator/plan-dependencies.js +148 -0
  177. package/dist/tools/coordinator/plan-dependencies.js.map +1 -0
  178. package/dist/types/agent/supervisor.d.ts +15 -0
  179. package/dist/types/agent/supervisor.d.ts.map +1 -1
  180. package/dist/types/connector/core.d.ts +34 -0
  181. package/dist/types/connector/core.d.ts.map +1 -1
  182. package/dist/types/connector/definition.d.ts +10 -0
  183. package/dist/types/connector/definition.d.ts.map +1 -1
  184. package/dist/types/connector/mcp.d.ts +13 -0
  185. package/dist/types/connector/mcp.d.ts.map +1 -1
  186. package/dist/types/rag/retrieval.d.ts +16 -0
  187. package/dist/types/rag/retrieval.d.ts.map +1 -1
  188. package/dist/types/rag/storage.d.ts +9 -0
  189. package/dist/types/rag/storage.d.ts.map +1 -1
  190. package/dist/types/rag/vector.d.ts +11 -0
  191. package/dist/types/rag/vector.d.ts.map +1 -1
  192. package/dist/types/router/task-router.d.ts +19 -0
  193. package/dist/types/router/task-router.d.ts.map +1 -1
  194. package/dist/types/run/prepare-step.d.ts +56 -3
  195. package/dist/types/run/prepare-step.d.ts.map +1 -1
  196. package/dist/types/toolset/index.d.ts +22 -0
  197. package/dist/types/toolset/index.d.ts.map +1 -1
  198. package/package.json +1 -1
  199. package/src/advisory/__tests__/consultation-context.test.ts +191 -0
  200. package/src/advisory/context.ts +32 -0
  201. package/src/advisory/executor.ts +30 -3
  202. package/src/compaction/__tests__/context-reducer.test.ts +239 -0
  203. package/src/compaction/factory.ts +7 -0
  204. package/src/compaction/index.ts +8 -0
  205. package/src/compaction/interface.ts +13 -0
  206. package/src/compaction/managers/null.ts +3 -0
  207. package/src/compaction/managers/slidingWindow.ts +6 -0
  208. package/src/compaction/managers/structured.ts +10 -0
  209. package/src/compaction/reducer.ts +154 -0
  210. package/src/connector/builtins/__tests__/oauth2-auth.test.ts +73 -0
  211. package/src/connector/builtins/http.test.ts +28 -2
  212. package/src/connector/builtins/http.ts +26 -2
  213. package/src/connector/index.ts +6 -2
  214. package/src/connector/mcp/__tests__/prompts-and-lifecycle.test.ts +286 -0
  215. package/src/connector/mcp/client.ts +95 -0
  216. package/src/connector/mcp/discovery.ts +19 -4
  217. package/src/connector/mcp/index.ts +6 -2
  218. package/src/connector/mcp/server.ts +101 -3
  219. package/src/manager/agent/__tests__/depth-limit-authority.test.ts +74 -0
  220. package/src/plugin/__tests__/discovery-scopes.test.ts +133 -0
  221. package/src/plugin/__tests__/enable-contributions.test.ts +5 -1
  222. package/src/plugin/__tests__/mcp-admission.test.ts +242 -0
  223. package/src/plugin/lifecycle.ts +57 -1
  224. package/src/plugin/loader.ts +57 -3
  225. package/src/public-runtime.ts +8 -0
  226. package/src/public-types.ts +5 -0
  227. package/src/rag/__tests__/namespace-isolation.test.ts +109 -0
  228. package/src/rag/ingestion.ts +1 -0
  229. package/src/rag/retriever.ts +2 -0
  230. package/src/rag/vector-store.ts +5 -0
  231. package/src/registry/tool/execute.ts +123 -119
  232. package/src/runtime/query/__tests__/per-step-skills.test.ts +154 -0
  233. package/src/runtime/query/__tests__/per-step-tool-choice.test.ts +180 -0
  234. package/src/runtime/query/__tests__/resume-run.test.ts +262 -0
  235. package/src/runtime/query/index.ts +39 -0
  236. package/src/runtime/query/iteration/index.ts +103 -34
  237. package/src/runtime/query/iteration/phases/compaction-model-routing.test.ts +125 -0
  238. package/src/runtime/query/iteration/phases/compaction.ts +106 -5
  239. package/src/runtime/query/iteration/phases/context-reducer-dispatch.test.ts +238 -0
  240. package/src/runtime/query/iteration/phases/context.ts +11 -0
  241. package/src/runtime/query/resume-run.ts +93 -0
  242. package/src/telemetry/__tests__/model-call-span.test.ts +189 -0
  243. package/src/telemetry/__tests__/span-closure.test.ts +153 -0
  244. package/src/tools/advisory/index.ts +1 -1
  245. package/src/tools/coordinator/__tests__/plan-dependencies.test.ts +186 -0
  246. package/src/tools/coordinator/index.ts +14 -1
  247. package/src/tools/coordinator/plan-dependencies.ts +175 -0
  248. package/src/types/agent/supervisor.ts +15 -0
  249. package/src/types/connector/core.ts +34 -0
  250. package/src/types/connector/definition.ts +10 -0
  251. package/src/types/connector/mcp.ts +14 -0
  252. package/src/types/rag/retrieval.ts +16 -0
  253. package/src/types/rag/storage.ts +9 -0
  254. package/src/types/rag/vector.ts +11 -0
  255. package/src/types/router/task-router.ts +19 -0
  256. package/src/types/run/prepare-step.ts +58 -3
  257. package/src/types/toolset/index.ts +22 -0
@@ -0,0 +1,242 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ import type { MCPToolDefinition } from '../../types/connector/index.js'
7
+
8
+ /**
9
+ * A plugin's MCP server decided what entered the agent's tool registry.
10
+ *
11
+ * `MCPToolDiscovery` has held the admission boundary — a per-server
12
+ * allow/deny policy, and detection for a server whose tool set changes
13
+ * between discoveries — since it was written, and nothing outside its own
14
+ * tests ever constructed one. `PluginLifecycleManager.attachMCPServer`, the
15
+ * only code in the tree that connects a real MCP server, called
16
+ * `client.listTools()` and registered whatever came back.
17
+ *
18
+ * So least privilege was inverted at the one place it mattered: the remote
19
+ * side chose. Tools land as `deferred` and a run's `allowedTools` filters the
20
+ * model-visible catalogue, so this was not "arbitrary tools reach the model
21
+ * immediately" — but the check written for exactly this was not on the path.
22
+ */
23
+
24
+ let advertised: MCPToolDefinition[] = []
25
+ let clientCount = 0
26
+
27
+ vi.mock('../../connector/mcp/client.js', () => ({
28
+ MCPClient: class {
29
+ readonly id: string
30
+ private serverName: string
31
+ constructor(config: { serverName: string }) {
32
+ this.id = `client_${clientCount++}`
33
+ this.serverName = config.serverName
34
+ }
35
+ async connect(): Promise<void> {}
36
+ async disconnect(): Promise<void> {}
37
+ isConnected(): boolean {
38
+ return true
39
+ }
40
+ getState() {
41
+ return { serverName: this.serverName }
42
+ }
43
+ async listTools(): Promise<MCPToolDefinition[]> {
44
+ return advertised
45
+ }
46
+ },
47
+ }))
48
+
49
+ function tool(name: string): MCPToolDefinition {
50
+ return { name, description: name, inputSchema: { type: 'object', properties: {} } }
51
+ }
52
+
53
+ const log = {
54
+ debug: vi.fn(),
55
+ info: vi.fn(),
56
+ warn: vi.fn(),
57
+ error: vi.fn(),
58
+ child: () => log,
59
+ } as never
60
+
61
+ let root: string
62
+
63
+ interface Harness {
64
+ readonly registered: string[]
65
+ readonly manager: import('../lifecycle.js').PluginLifecycleManager
66
+ enable(name: string): Promise<import('../../types/ids/index.js').PluginId>
67
+ }
68
+
69
+ async function harness(config: Record<string, unknown> = {}): Promise<Harness> {
70
+ const { PluginLifecycleManager } = await import('../lifecycle.js')
71
+ const { PluginRegistry } = await import('../../registry/plugin/index.js')
72
+
73
+ const registered: string[] = []
74
+ const toolRegistry = {
75
+ register: (definition: { name: string }) => registered.push(definition.name),
76
+ unregister: () => undefined,
77
+ get: () => undefined,
78
+ } as never
79
+
80
+ const manager = new PluginLifecycleManager({
81
+ pluginRegistry: new PluginRegistry(),
82
+ toolRegistry,
83
+ log,
84
+ ...config,
85
+ } as never)
86
+
87
+ return {
88
+ registered,
89
+ manager,
90
+ enable: async (name: string) => {
91
+ const dir = join(root, name)
92
+ await mkdir(dir, { recursive: true })
93
+ await writeFile(
94
+ join(dir, 'plugin.json'),
95
+ JSON.stringify({
96
+ name,
97
+ version: '1.0.0',
98
+ description: 'MCP server plugin fixture',
99
+ mcpServers: [{ name: 'files', command: 'node', args: ['server.js'] }],
100
+ }),
101
+ 'utf-8',
102
+ )
103
+ const plugin = await manager.install(dir, 'project')
104
+ await manager.enable(plugin.id)
105
+ return plugin.id
106
+ },
107
+ }
108
+ }
109
+
110
+ beforeEach(async () => {
111
+ root = await mkdtemp(join(tmpdir(), 'namzu-mcp-admit-'))
112
+ clientCount = 0
113
+ advertised = [tool('read_file'), tool('write_file'), tool('delete_everything')]
114
+ })
115
+
116
+ afterEach(async () => {
117
+ await rm(root, { recursive: true, force: true })
118
+ vi.clearAllMocks()
119
+ })
120
+
121
+ describe('what a plugin server advertises is not what the registry gets', () => {
122
+ it('admits only the tools an allowlist names', async () => {
123
+ const h = await harness({ mcpToolPolicies: { files: { allow: ['read_file', 'write_file'] } } })
124
+
125
+ await h.enable('srv')
126
+
127
+ expect(h.registered.some((n) => n.endsWith('read_file'))).toBe(true)
128
+ expect(h.registered.some((n) => n.endsWith('write_file'))).toBe(true)
129
+ // The one the server offered and the host never agreed to.
130
+ expect(h.registered.some((n) => n.endsWith('delete_everything'))).toBe(false)
131
+ })
132
+
133
+ it('refuses a denied tool through the wildcard policy', async () => {
134
+ const h = await harness({ mcpToolPolicies: { '*': { deny: ['delete_everything'] } } })
135
+
136
+ await h.enable('srv')
137
+
138
+ expect(h.registered).toHaveLength(2)
139
+ expect(h.registered.some((n) => n.endsWith('delete_everything'))).toBe(false)
140
+ })
141
+
142
+ it('still admits everything when no policy is configured', async () => {
143
+ // Adding a boundary must not turn every existing host's working plugin
144
+ // into a broken one.
145
+ const h = await harness()
146
+
147
+ await h.enable('srv')
148
+
149
+ expect(h.registered).toHaveLength(3)
150
+ })
151
+
152
+ it('namespaces what it admits, exactly as before', async () => {
153
+ const h = await harness({ mcpToolPolicies: { files: { allow: ['read_file'] } } })
154
+
155
+ await h.enable('srv')
156
+
157
+ expect(h.registered[0]).toContain('mcp__files__read_file')
158
+ })
159
+ })
160
+
161
+ describe('a server that changes its tools between connections is reported', () => {
162
+ it('says nothing the first time, having nothing to compare against', async () => {
163
+ const onMCPToolDrift = vi.fn()
164
+ const h = await harness({ onMCPToolDrift })
165
+
166
+ await h.enable('srv')
167
+
168
+ expect(onMCPToolDrift).not.toHaveBeenCalled()
169
+ })
170
+
171
+ it('reports a tool the server grew after it was first approved', async () => {
172
+ const onMCPToolDrift = vi.fn()
173
+ const h = await harness({ onMCPToolDrift })
174
+ const first = await h.enable('srv-a')
175
+
176
+ // The rug pull: advertise something benign while a host is deciding,
177
+ // swap it afterwards. A NEW client connects to the SAME server, which
178
+ // is why the remembered set is keyed by server name — keyed by client
179
+ // id, as it was, every connection was its own first and this could
180
+ // never fire.
181
+ await h.manager.disable(first)
182
+ advertised = [...advertised, tool('exfiltrate')]
183
+ await h.enable('srv-b')
184
+
185
+ expect(onMCPToolDrift).toHaveBeenCalledTimes(1)
186
+ expect(onMCPToolDrift.mock.calls[0]?.[0]).toMatchObject({
187
+ serverName: 'files',
188
+ drift: { added: ['exfiltrate'] },
189
+ })
190
+ })
191
+
192
+ it('reports a tool whose shape changed under the same name', async () => {
193
+ const onMCPToolDrift = vi.fn()
194
+ const h = await harness({ onMCPToolDrift })
195
+ const first = await h.enable('srv-a')
196
+
197
+ await h.manager.disable(first)
198
+ // Same name, different input shape — the version a name-only check
199
+ // misses entirely.
200
+ advertised = [
201
+ {
202
+ name: 'read_file',
203
+ description: 'read a file',
204
+ inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
205
+ },
206
+ tool('write_file'),
207
+ tool('delete_everything'),
208
+ ]
209
+ await h.enable('srv-b')
210
+
211
+ expect(onMCPToolDrift).toHaveBeenCalledTimes(1)
212
+ expect(onMCPToolDrift.mock.calls[0]?.[0]).toMatchObject({ drift: { changed: ['read_file'] } })
213
+ })
214
+
215
+ it('says nothing when the server offers exactly what it offered before', async () => {
216
+ const onMCPToolDrift = vi.fn()
217
+ const h = await harness({ onMCPToolDrift })
218
+ const first = await h.enable('srv-a')
219
+
220
+ await h.manager.disable(first)
221
+ await h.enable('srv-b')
222
+
223
+ expect(onMCPToolDrift).not.toHaveBeenCalled()
224
+ })
225
+
226
+ it('compares what was ADMITTED, not what was advertised', async () => {
227
+ const onMCPToolDrift = vi.fn()
228
+ const h = await harness({
229
+ mcpToolPolicies: { files: { allow: ['read_file'] } },
230
+ onMCPToolDrift,
231
+ })
232
+ const first = await h.enable('srv-a')
233
+
234
+ await h.manager.disable(first)
235
+ // A tool the policy refuses either way. Reporting drift for it would
236
+ // train a host to ignore the warning that matters.
237
+ advertised = [...advertised, tool('another_denied_one')]
238
+ await h.enable('srv-b')
239
+
240
+ expect(onMCPToolDrift).not.toHaveBeenCalled()
241
+ })
242
+ })
@@ -2,6 +2,9 @@ import { join } from 'node:path'
2
2
  import { pathToFileURL } from 'node:url'
3
3
  import { mcpToolToToolDefinition } from '../connector/mcp/adapter.js'
4
4
  import { MCPClient } from '../connector/mcp/client.js'
5
+ import { MCPToolDiscovery } from '../connector/mcp/discovery.js'
6
+ import type { MCPToolDiscoveryOptions } from '../connector/mcp/discovery.js'
7
+ import type { MCPToolPolicy } from '../connector/mcp/policy.js'
5
8
  import {
6
9
  DEFAULT_HOOK_PRIORITY,
7
10
  HOOK_TIMEOUT_MS,
@@ -37,6 +40,32 @@ export interface PluginLifecycleManagerConfig {
37
40
  toolRegistry: ToolRegistryContract
38
41
  log: Logger
39
42
  hookTimeoutMs?: number
43
+
44
+ /**
45
+ * What each MCP server a plugin brings is allowed to contribute, keyed by
46
+ * server name. `'*'` covers every server not named explicitly.
47
+ *
48
+ * Absent admits everything, which is what this path did unconditionally
49
+ * until now. `MCPToolDiscovery` has held this boundary — and the drift
50
+ * detection below — since it was written, and nothing outside its own
51
+ * tests ever constructed one: `attachMCPServer` called `listTools()`
52
+ * directly and registered whatever came back. So the least-privilege
53
+ * check existed, was tested, was exported, and was not on the path any
54
+ * real MCP server takes.
55
+ */
56
+ mcpToolPolicies?: Readonly<Record<string, MCPToolPolicy>>
57
+
58
+ /**
59
+ * Called when a server's admitted tool set differs from the last time it
60
+ * was discovered.
61
+ *
62
+ * Reported rather than blocked, for the reason `MCPToolDiscovery`
63
+ * already gives: a development server legitimately changes between runs,
64
+ * while a production one changing mid-session is the rug pull — advertise
65
+ * something benign at approval time, swap it afterwards. Only the host
66
+ * knows which it is looking at.
67
+ */
68
+ onMCPToolDrift?: MCPToolDiscoveryOptions['onDrift']
40
69
  }
41
70
 
42
71
  export class PluginLifecycleManager {
@@ -55,12 +84,32 @@ export class PluginLifecycleManager {
55
84
  private pluginContributions: Map<PluginId, PluginContributionRecord> = new Map()
56
85
  private hookTimeoutMs: number
57
86
  private log: Logger
87
+ /**
88
+ * The admission boundary for everything a plugin's MCP servers advertise.
89
+ *
90
+ * One instance for the whole manager rather than one per plugin, and that
91
+ * is the part that matters: the instance is what remembers each server's
92
+ * previously admitted tool set, so a server that changes between one
93
+ * plugin being disabled and the next being enabled is still noticed. A
94
+ * per-plugin instance would forget on every teardown, which is precisely
95
+ * the window a rug pull uses.
96
+ *
97
+ * Its client list is deliberately left empty — `discoverFrom` takes the
98
+ * client directly, so registering it here would be bookkeeping nothing
99
+ * reads.
100
+ */
101
+ private mcpDiscovery: MCPToolDiscovery
58
102
 
59
103
  constructor(config: PluginLifecycleManagerConfig) {
60
104
  this.pluginRegistry = config.pluginRegistry
61
105
  this.toolRegistry = config.toolRegistry
62
106
  this.hookTimeoutMs = config.hookTimeoutMs ?? HOOK_TIMEOUT_MS
63
107
  this.log = config.log.child({ component: 'PluginLifecycleManager' })
108
+ this.mcpDiscovery = new MCPToolDiscovery([], {
109
+ ...(config.mcpToolPolicies ? { policies: config.mcpToolPolicies } : {}),
110
+ ...(config.onMCPToolDrift ? { onDrift: config.onMCPToolDrift } : {}),
111
+ logger: this.log,
112
+ })
64
113
  }
65
114
 
66
115
  /**
@@ -257,7 +306,14 @@ export class PluginLifecycleManager {
257
306
  await client.connect()
258
307
  contributions.mcpClients.push(client)
259
308
 
260
- const mcpTools = await client.listTools()
309
+ // Through the boundary, not around it. This used to call
310
+ // `client.listTools()` and register everything the server answered
311
+ // with, so the remote side decided what entered the agent's registry
312
+ // — least privilege inverted. `discoverFrom` applies the per-server
313
+ // allow/deny policy and reports a tool set that changed since the
314
+ // last discovery.
315
+ const discovered = await this.mcpDiscovery.discoverFrom(client)
316
+ const mcpTools = discovered.map((d) => d.tool)
261
317
  for (const mcpTool of mcpTools) {
262
318
  const baseDef = mcpToolToToolDefinition(mcpTool, client, config.name)
263
319
  // Double-underscore between serverName and toolName so that e.g.
@@ -88,23 +88,77 @@ export function assertEnableable(manifest: PluginManifest): void {
88
88
  )
89
89
  }
90
90
 
91
+ /** Where a plugin came from. Project plugins ship with the repo; user plugins live in the home directory. */
92
+ export type PluginScope = 'project' | 'user'
93
+
91
94
  /**
92
- * Discovers plugin directories from both project-level and user-level locations.
93
- * Returns categorized arrays of absolute paths.
95
+ * The discovery half of {@link PluginRuntimeConfig}.
96
+ *
97
+ * Structural rather than an import so this module keeps depending on nothing
98
+ * — a parsed `PluginRuntimeConfig` satisfies it as-is.
99
+ */
100
+ export interface PluginDiscoveryOptions {
101
+ /** Whether the plugin runtime is on at all. `false` discovers nothing. */
102
+ readonly enabled?: boolean
103
+ /** Whether to scan for plugins. `false` discovers nothing. */
104
+ readonly autoDiscovery?: boolean
105
+ /** Which locations may be scanned. Absent means both. */
106
+ readonly allowedScopes?: readonly PluginScope[]
107
+ }
108
+
109
+ /**
110
+ * Find plugin directories, in the locations the caller permits.
111
+ *
112
+ * `allowedScopes` is a trust boundary, not a filter applied afterwards. A
113
+ * plugin is arbitrary code with hooks into tool execution, and the two scopes
114
+ * are not equally trusted: project plugins are reviewable in the repo the
115
+ * agent is working on, while user plugins come from a home directory the
116
+ * repo's reviewers never see. `['project']` is how a host says the second
117
+ * kind is not allowed to run here.
118
+ *
119
+ * It was previously declared and unread — `PluginRuntimeConfig` carried
120
+ * `enabled`, `autoDiscovery` and `allowedScopes`, nothing anywhere consulted
121
+ * any of them, and this function scanned both locations unconditionally. A
122
+ * host who set `allowedScopes: ['project']` got user plugins anyway, from a
123
+ * setting that reads exactly like a boundary.
124
+ *
125
+ * A disallowed scope is NOT SCANNED rather than scanned and dropped.
126
+ * Filtering after the fact still reads the directory, which is both pointless
127
+ * work and a small disclosure — the returned count would tell a caller how
128
+ * many plugins live somewhere they said they would not look.
129
+ *
130
+ * Passing nothing keeps the old behaviour: both scopes, no gate. Existing
131
+ * callers are unaffected, and a caller who opts in gets what the config says.
94
132
  */
95
133
  export async function discoverAllPluginDirs(
96
134
  workingDirectory?: string,
135
+ options?: PluginDiscoveryOptions,
97
136
  ): Promise<{ project: string[]; user: string[] }> {
137
+ if (options?.enabled === false || options?.autoDiscovery === false) {
138
+ logger.debug('Plugin discovery skipped', {
139
+ enabled: options.enabled,
140
+ autoDiscovery: options.autoDiscovery,
141
+ })
142
+ return { project: [], user: [] }
143
+ }
144
+
145
+ const scopes = options?.allowedScopes
146
+ const mayScan = (scope: PluginScope): boolean => !scopes || scopes.includes(scope)
147
+
98
148
  const projectDir = workingDirectory
99
149
  ? join(workingDirectory, PROJECT_PLUGIN_DIR)
100
150
  : join(process.cwd(), PROJECT_PLUGIN_DIR)
101
151
  const userDir = join(homedir(), USER_PLUGIN_DIR)
102
152
 
103
- const [project, user] = await Promise.all([discoverPlugins(projectDir), discoverPlugins(userDir)])
153
+ const [project, user] = await Promise.all([
154
+ mayScan('project') ? discoverPlugins(projectDir) : Promise.resolve([]),
155
+ mayScan('user') ? discoverPlugins(userDir) : Promise.resolve([]),
156
+ ])
104
157
 
105
158
  logger.debug('Plugin discovery complete', {
106
159
  projectCount: project.length,
107
160
  userCount: user.length,
161
+ ...(scopes ? { allowedScopes: scopes } : {}),
108
162
  })
109
163
 
110
164
  return { project, user }
@@ -89,6 +89,12 @@ export type { ToolGrantKeys } from './runtime/query/tool-grants.js'
89
89
  export { toWireRunStatus } from './contracts/run-status.js'
90
90
  // Durable run state: the snapshot a different process picks a run up from.
91
91
  export { captureRunState, loadRunState } from './runtime/query/run-state.js'
92
+ // …and the driver that joins the snapshot back to a running loop. Without
93
+ // it every host wrote the same wiring, and in practice none did.
94
+ export { resumeRun } from './runtime/query/resume-run.js'
95
+ export type { ResumeOutcome, ResumeRunParams } from './runtime/query/resume-run.js'
96
+ // The scope type was internal, so a host calling `loadRunState` could not
97
+ // name the argument it had to construct.
92
98
  export type { RunStateScope } from './runtime/query/run-state.js'
93
99
  export { prepareReplayState } from './runtime/query/replay/prepare.js'
94
100
  export { listCheckpoints } from './runtime/query/replay/list.js'
@@ -261,6 +267,7 @@ export {
261
267
  HybridExecutionContext,
262
268
  LocalExecutionContext,
263
269
  MCPClient,
270
+ MCPMethodNotFound,
264
271
  MCPConnectorBridge,
265
272
  MCPServer,
266
273
  MCPToolDiscovery,
@@ -496,6 +503,7 @@ export {
496
503
  lookupContextWindow,
497
504
  resolveContextWindow,
498
505
  createConversationManager,
506
+ createSlidingWindowReducer,
499
507
  extractFromAssistantMessage,
500
508
  extractFromToolCall,
501
509
  extractFromToolResult,
@@ -134,6 +134,7 @@ export type {
134
134
  HybridExecutionContextOptions,
135
135
  LocalExecutionContextOptions,
136
136
  MCPServerResourceProvider,
137
+ MCPServerPromptProvider,
137
138
  MCPServerToolProvider,
138
139
  RemoteExecutionContextOptions,
139
140
  TenantConnectorManagerConfig,
@@ -157,6 +158,10 @@ export type { ToolCallContext } from './verification/index.js'
157
158
  export type { DiskSessionStoreConfig, LinkageView } from './store/session/index.js'
158
159
 
159
160
  export type {
161
+ ContextReducer,
162
+ ContextReduction,
163
+ ContextReductionReason,
164
+ SlidingWindowOptions,
160
165
  ConversationManager,
161
166
  CompactionStrategy,
162
167
  DanglingResult,
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { EmbeddingProvider, TenantScope } from '../../types/rag/index.js'
4
+ import { DefaultIngestionPipeline } from '../ingestion.js'
5
+ import { DefaultRetriever } from '../retriever.js'
6
+ import { InMemoryVectorStore } from '../vector-store.js'
7
+
8
+ /**
9
+ * `TenantScope.namespace` and `KnowledgeBaseConfig.namespace` were declared
10
+ * from the start and neither reached storage. Ingestion copied
11
+ * `scope.tenantId` onto every chunk and dropped the namespace; the store
12
+ * filtered on tenant alone. So a partition a host asked for did not exist,
13
+ * and every namespace in a tenant saw every other one's documents.
14
+ */
15
+
16
+ /** Deterministic and content-derived, so similarity is stable across runs. */
17
+ const embedder: EmbeddingProvider = {
18
+ async embed(texts: string[]) {
19
+ return texts.map((t) => [t.length % 7, t.charCodeAt(0) % 5, 1])
20
+ },
21
+ async embedQuery(text: string) {
22
+ return [text.length % 7, text.charCodeAt(0) % 5, 1]
23
+ },
24
+ dimensions: 3,
25
+ model: 'test-embedder',
26
+ id: 'test-embedder',
27
+ }
28
+
29
+ const tenant = 'tnt_one' as TenantScope['tenantId']
30
+
31
+ function scope(namespace?: string): TenantScope {
32
+ return { tenantId: tenant, ...(namespace !== undefined ? { namespace } : {}) }
33
+ }
34
+
35
+ async function seed() {
36
+ const store = new InMemoryVectorStore()
37
+ const ingestion = new DefaultIngestionPipeline(store, embedder)
38
+ const kb = 'kb_one' as never
39
+
40
+ await ingestion.ingest('alpha partition content', {}, scope('alpha'), kb)
41
+ await ingestion.ingest('beta partition content', {}, scope('beta'), kb)
42
+ await ingestion.ingest('default partition content', {}, scope(), kb)
43
+
44
+ return { store, retriever: new DefaultRetriever(store, embedder), kb }
45
+ }
46
+
47
+ const texts = (r: { chunks: { chunk: { content: string } }[] }) =>
48
+ r.chunks.map((c) => c.chunk.content)
49
+
50
+ describe('a namespace partitions what a query can see', () => {
51
+ it('returns only its own partition', async () => {
52
+ const { retriever, kb } = await seed()
53
+
54
+ const found = await retriever.retrieve({ text: 'partition content' }, scope('alpha'), kb)
55
+
56
+ expect(texts(found)).toEqual(['alpha partition content'])
57
+ })
58
+
59
+ it('does not leak the other partition', async () => {
60
+ const { retriever, kb } = await seed()
61
+
62
+ const found = await retriever.retrieve({ text: 'partition content' }, scope('beta'), kb)
63
+
64
+ expect(texts(found).join()).not.toContain('alpha')
65
+ })
66
+
67
+ it('treats an absent namespace as the default partition, not as no filter', async () => {
68
+ const { retriever, kb } = await seed()
69
+
70
+ const found = await retriever.retrieve({ text: 'partition content' }, scope(), kb)
71
+
72
+ // The whole point. Reading absence as "no filter" is how the boundary
73
+ // leaks: a caller who never asked for a namespace would see every
74
+ // namespaced chunk in the tenant.
75
+ expect(texts(found)).toEqual(['default partition content'])
76
+ })
77
+
78
+ it('keeps tenants apart as it always did', async () => {
79
+ const { retriever, kb } = await seed()
80
+
81
+ const found = await retriever.retrieve(
82
+ { text: 'partition content' },
83
+ {
84
+ tenantId: 'tnt_other' as TenantScope['tenantId'],
85
+ namespace: 'alpha',
86
+ },
87
+ kb,
88
+ )
89
+
90
+ expect(found.chunks).toEqual([])
91
+ })
92
+
93
+ it('stamps the namespace onto the chunk at ingest', async () => {
94
+ const { store } = await seed()
95
+
96
+ const results = await store.search({
97
+ embedding: await embedder.embedQuery('partition content'),
98
+ topK: 10,
99
+ tenantId: tenant,
100
+ namespace: 'alpha',
101
+ })
102
+
103
+ // Ingest is where it has to land — filtering at search against a value
104
+ // nothing writes returns zero rows, and "no results" reads as "nothing
105
+ // matched" rather than "this scope was never stored".
106
+ expect(results.every((r) => r.chunk.content.startsWith('alpha'))).toBe(true)
107
+ expect(results.length).toBeGreaterThan(0)
108
+ })
109
+ })
@@ -48,6 +48,7 @@ export class DefaultIngestionPipeline implements IngestionPipeline {
48
48
  documentId,
49
49
  knowledgeBaseId,
50
50
  tenantId: scope.tenantId,
51
+ ...(scope.namespace !== undefined ? { namespace: scope.namespace } : {}),
51
52
  content: cc.content,
52
53
  index: cc.index,
53
54
  tokenCount: estimateTokens(cc.content),
@@ -76,6 +76,7 @@ export class DefaultRetriever implements Retriever {
76
76
  embedding,
77
77
  topK: config.topK,
78
78
  tenantId: scope.tenantId,
79
+ ...(scope.namespace !== undefined ? { namespace: scope.namespace } : {}),
79
80
  knowledgeBaseId,
80
81
  minScore: config.minScore,
81
82
  })
@@ -92,6 +93,7 @@ export class DefaultRetriever implements Retriever {
92
93
  embedding,
93
94
  topK: config.topK * 2,
94
95
  tenantId: scope.tenantId,
96
+ ...(scope.namespace !== undefined ? { namespace: scope.namespace } : {}),
95
97
  knowledgeBaseId,
96
98
  minScore: 0,
97
99
  })
@@ -20,6 +20,11 @@ export class InMemoryVectorStore implements VectorStore {
20
20
 
21
21
  for (const chunk of this.chunks.values()) {
22
22
  if (chunk.tenantId !== query.tenantId) continue
23
+ // Equality INCLUDING absence: an omitted namespace is the default
24
+ // partition, not the absence of a filter. Treating it as no filter
25
+ // is how the boundary leaks — a caller who never asked for a
26
+ // namespace would see every namespaced chunk in the tenant.
27
+ if (chunk.namespace !== query.namespace) continue
23
28
  if (query.knowledgeBaseId && chunk.knowledgeBaseId !== query.knowledgeBaseId) continue
24
29
  if (!chunk.embedding) continue
25
30