@dobbyai/mcp-external 1.1.0 → 1.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 (289) hide show
  1. package/README.md +44 -14
  2. package/dist/__tests__/agent-mail-tools.test.js +19 -26
  3. package/dist/__tests__/agent-mail-tools.test.js.map +1 -1
  4. package/dist/__tests__/auth.test.js +50 -5
  5. package/dist/__tests__/auth.test.js.map +1 -1
  6. package/dist/__tests__/doctor.test.d.ts +16 -0
  7. package/dist/__tests__/doctor.test.d.ts.map +1 -0
  8. package/dist/__tests__/doctor.test.js +258 -0
  9. package/dist/__tests__/doctor.test.js.map +1 -0
  10. package/dist/__tests__/external-agents-tools.test.d.ts +11 -0
  11. package/dist/__tests__/external-agents-tools.test.d.ts.map +1 -0
  12. package/dist/__tests__/external-agents-tools.test.js +619 -0
  13. package/dist/__tests__/external-agents-tools.test.js.map +1 -0
  14. package/dist/__tests__/gateway-bearer.test.d.ts +12 -0
  15. package/dist/__tests__/gateway-bearer.test.d.ts.map +1 -0
  16. package/dist/__tests__/gateway-bearer.test.js +112 -0
  17. package/dist/__tests__/gateway-bearer.test.js.map +1 -0
  18. package/dist/__tests__/gateway-key-resolver.test.d.ts +10 -0
  19. package/dist/__tests__/gateway-key-resolver.test.d.ts.map +1 -0
  20. package/dist/__tests__/gateway-key-resolver.test.js +125 -0
  21. package/dist/__tests__/gateway-key-resolver.test.js.map +1 -0
  22. package/dist/__tests__/http-concurrency.test.d.ts +20 -0
  23. package/dist/__tests__/http-concurrency.test.d.ts.map +1 -0
  24. package/dist/__tests__/http-concurrency.test.js +254 -0
  25. package/dist/__tests__/http-concurrency.test.js.map +1 -0
  26. package/dist/__tests__/integration/protocol.test.js +14 -8
  27. package/dist/__tests__/integration/protocol.test.js.map +1 -1
  28. package/dist/__tests__/mcp-integrations.test.js +20 -30
  29. package/dist/__tests__/mcp-integrations.test.js.map +1 -1
  30. package/dist/__tests__/memory-graph-tools.test.js +27 -38
  31. package/dist/__tests__/memory-graph-tools.test.js.map +1 -1
  32. package/dist/__tests__/registry-dispatch.test.d.ts +11 -0
  33. package/dist/__tests__/registry-dispatch.test.d.ts.map +1 -0
  34. package/dist/__tests__/registry-dispatch.test.js +30 -0
  35. package/dist/__tests__/registry-dispatch.test.js.map +1 -0
  36. package/dist/__tests__/registry-listing-parity.test.d.ts +2 -0
  37. package/dist/__tests__/registry-listing-parity.test.d.ts.map +1 -0
  38. package/dist/__tests__/registry-listing-parity.test.js +89 -0
  39. package/dist/__tests__/registry-listing-parity.test.js.map +1 -0
  40. package/dist/__tests__/role-cap.test.d.ts +2 -0
  41. package/dist/__tests__/role-cap.test.d.ts.map +1 -0
  42. package/dist/__tests__/role-cap.test.js +113 -0
  43. package/dist/__tests__/role-cap.test.js.map +1 -0
  44. package/dist/__tests__/schedule-tools.test.js +15 -20
  45. package/dist/__tests__/schedule-tools.test.js.map +1 -1
  46. package/dist/__tests__/server-gateway-key.test.d.ts +18 -0
  47. package/dist/__tests__/server-gateway-key.test.d.ts.map +1 -0
  48. package/dist/__tests__/server-gateway-key.test.js +148 -0
  49. package/dist/__tests__/server-gateway-key.test.js.map +1 -0
  50. package/dist/__tests__/server-user-scoped.test.d.ts +34 -0
  51. package/dist/__tests__/server-user-scoped.test.d.ts.map +1 -0
  52. package/dist/__tests__/server-user-scoped.test.js +577 -0
  53. package/dist/__tests__/server-user-scoped.test.js.map +1 -0
  54. package/dist/__tests__/task-id-pattern.test.d.ts +12 -0
  55. package/dist/__tests__/task-id-pattern.test.d.ts.map +1 -0
  56. package/dist/__tests__/task-id-pattern.test.js +43 -0
  57. package/dist/__tests__/task-id-pattern.test.js.map +1 -0
  58. package/dist/__tests__/tiers.test.js +152 -56
  59. package/dist/__tests__/tiers.test.js.map +1 -1
  60. package/dist/__tests__/tools/hosted-agents.test.d.ts +9 -0
  61. package/dist/__tests__/tools/hosted-agents.test.d.ts.map +1 -0
  62. package/dist/__tests__/tools/hosted-agents.test.js +301 -0
  63. package/dist/__tests__/tools/hosted-agents.test.js.map +1 -0
  64. package/dist/__tests__/tools/llm-providers.test.d.ts +10 -0
  65. package/dist/__tests__/tools/llm-providers.test.d.ts.map +1 -0
  66. package/dist/__tests__/tools/llm-providers.test.js +105 -0
  67. package/dist/__tests__/tools/llm-providers.test.js.map +1 -0
  68. package/dist/__tests__/tools/services.test.js +139 -98
  69. package/dist/__tests__/tools/services.test.js.map +1 -1
  70. package/dist/__tests__/tools.test.js +24 -15
  71. package/dist/__tests__/tools.test.js.map +1 -1
  72. package/dist/__tests__/transport.test.js +365 -23
  73. package/dist/__tests__/transport.test.js.map +1 -1
  74. package/dist/__tests__/validate-credentials.test.d.ts +9 -0
  75. package/dist/__tests__/validate-credentials.test.d.ts.map +1 -0
  76. package/dist/__tests__/validate-credentials.test.js +172 -0
  77. package/dist/__tests__/validate-credentials.test.js.map +1 -0
  78. package/dist/__tests__/web-eval-tools.test.js +21 -32
  79. package/dist/__tests__/web-eval-tools.test.js.map +1 -1
  80. package/dist/__tests__/whoami.test.d.ts +11 -0
  81. package/dist/__tests__/whoami.test.d.ts.map +1 -0
  82. package/dist/__tests__/whoami.test.js +194 -0
  83. package/dist/__tests__/whoami.test.js.map +1 -0
  84. package/dist/__tests__/workflow-tools.test.js +20 -27
  85. package/dist/__tests__/workflow-tools.test.js.map +1 -1
  86. package/dist/__tests__/workloads.test.d.ts +9 -0
  87. package/dist/__tests__/workloads.test.d.ts.map +1 -0
  88. package/dist/__tests__/workloads.test.js +169 -0
  89. package/dist/__tests__/workloads.test.js.map +1 -0
  90. package/dist/auth/token-verifier.d.ts +11 -6
  91. package/dist/auth/token-verifier.d.ts.map +1 -1
  92. package/dist/auth/token-verifier.js +41 -19
  93. package/dist/auth/token-verifier.js.map +1 -1
  94. package/dist/connectors/claude-desktop.d.ts.map +1 -1
  95. package/dist/connectors/claude-desktop.js +3 -2
  96. package/dist/connectors/claude-desktop.js.map +1 -1
  97. package/dist/connectors/cursor.d.ts.map +1 -1
  98. package/dist/connectors/cursor.js +3 -2
  99. package/dist/connectors/cursor.js.map +1 -1
  100. package/dist/connectors/setup.d.ts.map +1 -1
  101. package/dist/connectors/setup.js +2 -1
  102. package/dist/connectors/setup.js.map +1 -1
  103. package/dist/connectors/vscode.d.ts.map +1 -1
  104. package/dist/connectors/vscode.js +3 -2
  105. package/dist/connectors/vscode.js.map +1 -1
  106. package/dist/connectors/windsurf.d.ts.map +1 -1
  107. package/dist/connectors/windsurf.js +3 -2
  108. package/dist/connectors/windsurf.js.map +1 -1
  109. package/dist/doctor.d.ts +34 -0
  110. package/dist/doctor.d.ts.map +1 -0
  111. package/dist/doctor.js +134 -0
  112. package/dist/doctor.js.map +1 -0
  113. package/dist/http-server.d.ts +24 -3
  114. package/dist/http-server.d.ts.map +1 -1
  115. package/dist/http-server.js +342 -62
  116. package/dist/http-server.js.map +1 -1
  117. package/dist/index.d.ts +5 -1
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +72 -208
  120. package/dist/index.js.map +1 -1
  121. package/dist/lib/consts.d.ts +21 -0
  122. package/dist/lib/consts.d.ts.map +1 -0
  123. package/dist/lib/consts.js +30 -0
  124. package/dist/lib/consts.js.map +1 -0
  125. package/dist/lib/dobby-client.d.ts +50 -23
  126. package/dist/lib/dobby-client.d.ts.map +1 -1
  127. package/dist/lib/dobby-client.js +105 -54
  128. package/dist/lib/dobby-client.js.map +1 -1
  129. package/dist/lib/gateway-key-resolver.d.ts +38 -0
  130. package/dist/lib/gateway-key-resolver.d.ts.map +1 -0
  131. package/dist/lib/gateway-key-resolver.js +59 -0
  132. package/dist/lib/gateway-key-resolver.js.map +1 -0
  133. package/dist/lib/log.d.ts +25 -0
  134. package/dist/lib/log.d.ts.map +1 -0
  135. package/dist/lib/log.js +36 -0
  136. package/dist/lib/log.js.map +1 -0
  137. package/dist/server.d.ts +175 -0
  138. package/dist/server.d.ts.map +1 -0
  139. package/dist/server.js +1017 -0
  140. package/dist/server.js.map +1 -0
  141. package/dist/tools/agent-mail.d.ts +0 -41
  142. package/dist/tools/agent-mail.d.ts.map +1 -1
  143. package/dist/tools/agent-mail.js +0 -222
  144. package/dist/tools/agent-mail.js.map +1 -1
  145. package/dist/tools/agent-versions.d.ts +93 -0
  146. package/dist/tools/agent-versions.d.ts.map +1 -0
  147. package/dist/tools/agent-versions.js +99 -0
  148. package/dist/tools/agent-versions.js.map +1 -0
  149. package/dist/tools/approvals.d.ts +0 -34
  150. package/dist/tools/approvals.d.ts.map +1 -1
  151. package/dist/tools/approvals.js +0 -81
  152. package/dist/tools/approvals.js.map +1 -1
  153. package/dist/tools/claude-code-sync.d.ts +40 -0
  154. package/dist/tools/claude-code-sync.d.ts.map +1 -0
  155. package/dist/tools/claude-code-sync.js +52 -0
  156. package/dist/tools/claude-code-sync.js.map +1 -0
  157. package/dist/tools/control-plane.d.ts +118 -0
  158. package/dist/tools/control-plane.d.ts.map +1 -0
  159. package/dist/tools/control-plane.js +135 -0
  160. package/dist/tools/control-plane.js.map +1 -0
  161. package/dist/tools/crew-builder.d.ts +0 -66
  162. package/dist/tools/crew-builder.d.ts.map +1 -1
  163. package/dist/tools/crew-builder.js +0 -185
  164. package/dist/tools/crew-builder.js.map +1 -1
  165. package/dist/tools/crewai.d.ts +0 -63
  166. package/dist/tools/crewai.d.ts.map +1 -1
  167. package/dist/tools/crewai.js +0 -145
  168. package/dist/tools/crewai.js.map +1 -1
  169. package/dist/tools/exa.d.ts +0 -35
  170. package/dist/tools/exa.d.ts.map +1 -1
  171. package/dist/tools/exa.js +0 -103
  172. package/dist/tools/exa.js.map +1 -1
  173. package/dist/tools/external-agents.d.ts +440 -0
  174. package/dist/tools/external-agents.d.ts.map +1 -0
  175. package/dist/tools/external-agents.js +386 -0
  176. package/dist/tools/external-agents.js.map +1 -0
  177. package/dist/tools/github-mcp.d.ts +0 -75
  178. package/dist/tools/github-mcp.d.ts.map +1 -1
  179. package/dist/tools/github-mcp.js +0 -162
  180. package/dist/tools/github-mcp.js.map +1 -1
  181. package/dist/tools/governance.d.ts +162 -18
  182. package/dist/tools/governance.d.ts.map +1 -1
  183. package/dist/tools/governance.js +208 -93
  184. package/dist/tools/governance.js.map +1 -1
  185. package/dist/tools/hosted-agents.d.ts +216 -0
  186. package/dist/tools/hosted-agents.d.ts.map +1 -0
  187. package/dist/tools/hosted-agents.js +214 -0
  188. package/dist/tools/hosted-agents.js.map +1 -0
  189. package/dist/tools/index.d.ts +3933 -1219
  190. package/dist/tools/index.d.ts.map +1 -1
  191. package/dist/tools/index.js +412 -393
  192. package/dist/tools/index.js.map +1 -1
  193. package/dist/tools/jira.d.ts +0 -176
  194. package/dist/tools/jira.d.ts.map +1 -1
  195. package/dist/tools/jira.js +0 -365
  196. package/dist/tools/jira.js.map +1 -1
  197. package/dist/tools/llm-providers.d.ts +50 -0
  198. package/dist/tools/llm-providers.d.ts.map +1 -0
  199. package/dist/tools/llm-providers.js +41 -0
  200. package/dist/tools/llm-providers.js.map +1 -0
  201. package/dist/tools/mcp-metrics.d.ts +0 -15
  202. package/dist/tools/mcp-metrics.d.ts.map +1 -1
  203. package/dist/tools/mcp-metrics.js +0 -217
  204. package/dist/tools/mcp-metrics.js.map +1 -1
  205. package/dist/tools/mcp-tools.d.ts +0 -29
  206. package/dist/tools/mcp-tools.d.ts.map +1 -1
  207. package/dist/tools/mcp-tools.js +0 -131
  208. package/dist/tools/mcp-tools.js.map +1 -1
  209. package/dist/tools/mem0.d.ts +0 -41
  210. package/dist/tools/mem0.d.ts.map +1 -1
  211. package/dist/tools/mem0.js +65 -165
  212. package/dist/tools/mem0.js.map +1 -1
  213. package/dist/tools/memory-graph.d.ts +0 -46
  214. package/dist/tools/memory-graph.d.ts.map +1 -1
  215. package/dist/tools/memory-graph.js +0 -386
  216. package/dist/tools/memory-graph.js.map +1 -1
  217. package/dist/tools/memory-stores.d.ts +141 -0
  218. package/dist/tools/memory-stores.d.ts.map +1 -0
  219. package/dist/tools/memory-stores.js +147 -0
  220. package/dist/tools/memory-stores.js.map +1 -0
  221. package/dist/tools/messages.d.ts +0 -20
  222. package/dist/tools/messages.d.ts.map +1 -1
  223. package/dist/tools/messages.js +0 -49
  224. package/dist/tools/messages.js.map +1 -1
  225. package/dist/tools/moltbook.d.ts +0 -236
  226. package/dist/tools/moltbook.d.ts.map +1 -1
  227. package/dist/tools/moltbook.js +0 -458
  228. package/dist/tools/moltbook.js.map +1 -1
  229. package/dist/tools/monitoring-attribution.d.ts +71 -0
  230. package/dist/tools/monitoring-attribution.d.ts.map +1 -0
  231. package/dist/tools/monitoring-attribution.js +74 -0
  232. package/dist/tools/monitoring-attribution.js.map +1 -0
  233. package/dist/tools/ops.d.ts +0 -80
  234. package/dist/tools/ops.d.ts.map +1 -1
  235. package/dist/tools/ops.js +0 -209
  236. package/dist/tools/ops.js.map +1 -1
  237. package/dist/tools/orchestrations.d.ts +109 -0
  238. package/dist/tools/orchestrations.d.ts.map +1 -0
  239. package/dist/tools/orchestrations.js +115 -0
  240. package/dist/tools/orchestrations.js.map +1 -0
  241. package/dist/tools/outcomes.d.ts +138 -0
  242. package/dist/tools/outcomes.d.ts.map +1 -0
  243. package/dist/tools/outcomes.js +136 -0
  244. package/dist/tools/outcomes.js.map +1 -0
  245. package/dist/tools/schedules.d.ts +0 -47
  246. package/dist/tools/schedules.d.ts.map +1 -1
  247. package/dist/tools/schedules.js +0 -109
  248. package/dist/tools/schedules.js.map +1 -1
  249. package/dist/tools/services.d.ts +0 -16
  250. package/dist/tools/services.d.ts.map +1 -1
  251. package/dist/tools/services.js +0 -135
  252. package/dist/tools/services.js.map +1 -1
  253. package/dist/tools/sessions.d.ts +124 -0
  254. package/dist/tools/sessions.d.ts.map +1 -0
  255. package/dist/tools/sessions.js +130 -0
  256. package/dist/tools/sessions.js.map +1 -0
  257. package/dist/tools/setup-status.d.ts +29 -0
  258. package/dist/tools/setup-status.d.ts.map +1 -0
  259. package/dist/tools/setup-status.js +35 -0
  260. package/dist/tools/setup-status.js.map +1 -0
  261. package/dist/tools/tasks.d.ts +0 -57
  262. package/dist/tools/tasks.d.ts.map +1 -1
  263. package/dist/tools/tasks.js +8 -217
  264. package/dist/tools/tasks.js.map +1 -1
  265. package/dist/tools/tiers.d.ts +23 -7
  266. package/dist/tools/tiers.d.ts.map +1 -1
  267. package/dist/tools/tiers.js +180 -79
  268. package/dist/tools/tiers.js.map +1 -1
  269. package/dist/tools/unified-agents.d.ts +0 -19
  270. package/dist/tools/unified-agents.d.ts.map +1 -1
  271. package/dist/tools/unified-agents.js +2 -103
  272. package/dist/tools/unified-agents.js.map +1 -1
  273. package/dist/tools/web-eval.d.ts +0 -43
  274. package/dist/tools/web-eval.d.ts.map +1 -1
  275. package/dist/tools/web-eval.js +0 -211
  276. package/dist/tools/web-eval.js.map +1 -1
  277. package/dist/tools/workflow.d.ts +0 -66
  278. package/dist/tools/workflow.d.ts.map +1 -1
  279. package/dist/tools/workflow.js +2 -409
  280. package/dist/tools/workflow.js.map +1 -1
  281. package/dist/tools/workloads.d.ts +81 -0
  282. package/dist/tools/workloads.d.ts.map +1 -0
  283. package/dist/tools/workloads.js +81 -0
  284. package/dist/tools/workloads.js.map +1 -0
  285. package/package.json +7 -6
  286. package/dist/tools/knowledge-base.d.ts +0 -144
  287. package/dist/tools/knowledge-base.d.ts.map +0 -1
  288. package/dist/tools/knowledge-base.js +0 -179
  289. package/dist/tools/knowledge-base.js.map +0 -1
package/dist/server.js ADDED
@@ -0,0 +1,1017 @@
1
+ /**
2
+ * MCP Server factory
3
+ *
4
+ * Builds the Dobby MCP Server with all request handlers registered.
5
+ * Extracted from index.ts (2026-07-12) so tests can exercise the REAL
6
+ * handlers — index.ts runs main() on import, which is unsafe under vitest.
7
+ *
8
+ * In STDIO mode: tenant context comes from env/CLI config; full tool access
9
+ * (the local key already proves the tenant).
10
+ * In HTTP mode: tenant context comes from the OAuth token; tool access is
11
+ * ENFORCED per token from authInfo.scopes — the introspection endpoint
12
+ * (web/app/oauth/token/introspect) derives the effective mcp scope from the
13
+ * org's plan (tier) + the token's own scopes (read vs execute).
14
+ */
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
17
+ import { createDobbyClient, DobbyClient } from "./lib/dobby-client.js";
18
+ import { resolveSessionGatewayKey } from "./lib/gateway-key-resolver.js";
19
+ import { getToolDefinitions, getToolAccessDenial, handleToolCall, resolveToolTier, isReadOnlyScopes, } from "./tools/index.js";
20
+ import { resourceDefinitions, resourceTemplates, handleResourceRead, } from "./resources/index.js";
21
+ import { promptDefinitions, handlePromptGet } from "./prompts/index.js";
22
+ import { classifyKeyType } from "./lib/consts.js";
23
+ /**
24
+ * Synthetic discovery tool for a user-scoped session (ADR-020). It is NOT a
25
+ * registered capability (keeps the registry at 131 + all golden/parity/tier
26
+ * tests byte-identical) — the server injects it into ListTools and handles it
27
+ * in CallTool only when the session is user-scoped, because it is meaningless
28
+ * for a tenant-pinned connection (you already know your one tenant).
29
+ */
30
+ const USER_SCOPED_TENANTS_TOOL = "list_my_tenants";
31
+ const USER_SCOPED_TENANTS_TOOL_DEF = {
32
+ name: USER_SCOPED_TENANTS_TOOL,
33
+ description: "List the tenants (workspaces) and organizations you can act in, with your " +
34
+ "live role in each. Use a returned tenant_id as the `tenant_id` argument to " +
35
+ "other tools in this user-scoped connection.",
36
+ inputSchema: {
37
+ type: "object",
38
+ properties: {},
39
+ additionalProperties: false,
40
+ },
41
+ annotations: {
42
+ title: "List my tenants",
43
+ readOnlyHint: true,
44
+ destructiveHint: false,
45
+ openWorldHint: false,
46
+ },
47
+ };
48
+ /**
49
+ * Structured "needs_selection" envelope — a user-scoped call omitted the scope
50
+ * argument it needs. Returned as machine-readable JSON (a client can JSON.parse
51
+ * `content[0].text` and read `status`/`argument`/`list_tool`) that is ALSO
52
+ * human-readable via the `message` field. isError stays true: the call could not
53
+ * proceed. UAT onboarding: return needs_selection, never silently inherit scope.
54
+ */
55
+ export function needsSelectionResult(select, argument, listTool) {
56
+ const article = select === "organization" ? "an" : "a";
57
+ const envelope = {
58
+ status: "needs_selection",
59
+ select,
60
+ argument,
61
+ list_tool: listTool,
62
+ message: `This is a user-scoped connection with no fixed ${select}. Call ` +
63
+ `"${listTool}" to see the ${select}s you can access, then re-call this ` +
64
+ `tool with ${article} "${argument}" argument naming the ${select} to act in.`,
65
+ };
66
+ return {
67
+ content: [
68
+ { type: "text", text: JSON.stringify(envelope, null, 2) },
69
+ ],
70
+ isError: true,
71
+ };
72
+ }
73
+ /**
74
+ * Structured "needs_pat" envelope — an identity/discovery call needs a Personal
75
+ * Access Token that is absent or not accepted. Names the env var to set and what
76
+ * it unlocks, as machine-readable JSON with a human `message` (UAT onboarding).
77
+ */
78
+ export function needsPatResult(unlocks, detail) {
79
+ const envelope = {
80
+ status: "needs_pat",
81
+ token_prefix: "dobby_pat_",
82
+ env_var: "DOBBY_API_KEY",
83
+ unlocks,
84
+ message: `A Personal Access Token (dobby_pat_…) is required to ${unlocks}. ` +
85
+ `Provide it as the DOBBY_API_KEY env var (or connect via OAuth to ` +
86
+ `mcp.dobby-ai.com), then retry. Create one under Settings → Personal ` +
87
+ `Access Tokens.` +
88
+ (detail ? ` (server said: ${detail})` : ""),
89
+ };
90
+ return {
91
+ content: [
92
+ { type: "text", text: JSON.stringify(envelope, null, 2) },
93
+ ],
94
+ isError: true,
95
+ };
96
+ }
97
+ /**
98
+ * Handle the synthetic `list_my_tenants` tool. Calls the web user-scoped
99
+ * endpoint (PAT-authed) that returns the caller's tenants + orgs + live role in
100
+ * each, so a user-scoped client can populate the `tenant_id` argument.
101
+ */
102
+ async function handleListMyTenants(apiUrl, token) {
103
+ try {
104
+ const response = await fetch(`${apiUrl}/api/v1/me/tenants`, {
105
+ method: "GET",
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ "X-API-Key": token,
109
+ },
110
+ });
111
+ if (!response.ok) {
112
+ const body = await response.text();
113
+ if (response.status === 401) {
114
+ return needsPatResult("list the tenants you can act in", body.slice(0, 200));
115
+ }
116
+ return {
117
+ content: [
118
+ {
119
+ type: "text",
120
+ text: `Failed to list your tenants (${response.status}): ${body}`,
121
+ },
122
+ ],
123
+ isError: true,
124
+ };
125
+ }
126
+ const data = (await response.json());
127
+ const tenants = data.tenants ?? [];
128
+ if (tenants.length === 0) {
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: "You are not a member of any tenants.",
134
+ },
135
+ ],
136
+ isError: false,
137
+ };
138
+ }
139
+ const lines = tenants.map((t) => `• ${t.name || t.tenant_id} — tenant_id: ${t.tenant_id}` +
140
+ (t.role ? ` · role: ${t.role}` : "") +
141
+ (t.region ? ` · region: ${t.region}` : "") +
142
+ (t.organization_id ? ` · org: ${t.organization_id}` : ""));
143
+ return {
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: `You can act in ${tenants.length} tenant(s). Pass the tenant_id of ` +
148
+ `the one you want when calling a tool:\n\n${lines.join("\n")}`,
149
+ },
150
+ ],
151
+ isError: false,
152
+ };
153
+ }
154
+ catch (error) {
155
+ return {
156
+ content: [
157
+ {
158
+ type: "text",
159
+ text: `Failed to list your tenants: ${error.message}`,
160
+ },
161
+ ],
162
+ isError: true,
163
+ };
164
+ }
165
+ }
166
+ /**
167
+ * Short-prompt onboarding: resolve the caller's SOLE tenant. Returns the
168
+ * tenant_id when the user belongs to exactly ONE tenant — so a user-scoped call
169
+ * that omitted `tenant_id` can proceed silently without a needs_selection
170
+ * round-trip — or null when there are zero, many, or the lookup fails (the
171
+ * caller then falls back to the structured needs_selection envelope). Reuses the
172
+ * same PAT-authed /api/v1/me/tenants endpoint as list_my_tenants.
173
+ */
174
+ export async function resolveSoleTenant(apiUrl, token) {
175
+ try {
176
+ const response = await fetch(`${apiUrl}/api/v1/me/tenants`, {
177
+ method: "GET",
178
+ headers: { "Content-Type": "application/json", "X-API-Key": token },
179
+ });
180
+ if (!response.ok)
181
+ return null;
182
+ const data = (await response.json());
183
+ const tenants = data.tenants ?? [];
184
+ return tenants.length === 1 ? tenants[0].tenant_id : null;
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ /**
191
+ * Synthetic discovery tool for the ORG side of a user-scoped session (ADR-020),
192
+ * the sibling of `list_my_tenants`. Also NOT a registered capability (keeps the
193
+ * registry at 131 + all golden/parity/tier tests byte-identical) — injected
194
+ * into ListTools and handled in CallTool only for a user-scoped session.
195
+ */
196
+ const USER_SCOPED_ORGS_TOOL = "list_my_orgs";
197
+ const USER_SCOPED_ORGS_TOOL_DEF = {
198
+ name: USER_SCOPED_ORGS_TOOL,
199
+ description: "List the organizations you can act in, with your live role in each. Use a " +
200
+ "returned organization_id as the `org_id` argument to org-scoped tools " +
201
+ "(memory stores, rubrics, orchestration configs) in this user-scoped connection.",
202
+ inputSchema: {
203
+ type: "object",
204
+ properties: {},
205
+ additionalProperties: false,
206
+ },
207
+ annotations: {
208
+ title: "List my organizations",
209
+ readOnlyHint: true,
210
+ destructiveHint: false,
211
+ openWorldHint: false,
212
+ },
213
+ };
214
+ /**
215
+ * Handle the synthetic `list_my_orgs` tool. Calls the web user-scoped endpoint
216
+ * (PAT-authed) that returns the caller's orgs + live role in each, so a
217
+ * user-scoped client can populate the `org_id` argument.
218
+ */
219
+ async function handleListMyOrgs(apiUrl, token) {
220
+ try {
221
+ const response = await fetch(`${apiUrl}/api/v1/me/organizations`, {
222
+ method: "GET",
223
+ headers: {
224
+ "Content-Type": "application/json",
225
+ "X-API-Key": token,
226
+ },
227
+ });
228
+ if (!response.ok) {
229
+ const body = await response.text();
230
+ if (response.status === 401) {
231
+ return needsPatResult("list the organizations you can act in", body.slice(0, 200));
232
+ }
233
+ return {
234
+ content: [
235
+ {
236
+ type: "text",
237
+ text: `Failed to list your organizations (${response.status}): ${body}`,
238
+ },
239
+ ],
240
+ isError: true,
241
+ };
242
+ }
243
+ const data = (await response.json());
244
+ const orgs = data.organizations ?? [];
245
+ if (orgs.length === 0) {
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text",
250
+ text: "You are not a member of any organizations.",
251
+ },
252
+ ],
253
+ isError: false,
254
+ };
255
+ }
256
+ const lines = orgs.map((o) => `• ${o.name || o.organization_id} — org_id: ${o.organization_id}` +
257
+ (o.role ? ` · role: ${o.role}` : ""));
258
+ return {
259
+ content: [
260
+ {
261
+ type: "text",
262
+ text: `You can act in ${orgs.length} organization(s). Pass the org_id of ` +
263
+ `the one you want as the "org_id" argument:\n\n${lines.join("\n")}`,
264
+ },
265
+ ],
266
+ isError: false,
267
+ };
268
+ }
269
+ catch (error) {
270
+ return {
271
+ content: [
272
+ {
273
+ type: "text",
274
+ text: `Failed to list your organizations: ${error.message}`,
275
+ },
276
+ ],
277
+ isError: true,
278
+ };
279
+ }
280
+ }
281
+ /**
282
+ * Synthetic `whoami` tool (onboarding identity echo). Unlike list_my_tenants /
283
+ * list_my_orgs it is available in BOTH transports and is NEVER gated or errored
284
+ * — its whole job is to answer "who am I, what can this key do, is a PAT present,
285
+ * which environment am I on?" so an onboarding agent never has to read `ps aux`
286
+ * or guess which org/tenant it silently inherited. NOT a registered capability
287
+ * (keeps the registry at 131 + all golden/parity/tier tests byte-identical).
288
+ */
289
+ const WHOAMI_TOOL = "whoami";
290
+ export const WHOAMI_TOOL_DEF = {
291
+ name: WHOAMI_TOOL,
292
+ description: "Report the current connection identity and what your credentials can do: " +
293
+ "transport, API URL, key type, the tenant/organization/user in context, whether " +
294
+ "a Personal Access Token is present, and your scopes. Never fails on missing " +
295
+ "credentials — it reports exactly what is set and what is missing. Call this " +
296
+ "FIRST to confirm which environment (prod vs dev) and which scope you are acting in.",
297
+ inputSchema: {
298
+ type: "object",
299
+ properties: {},
300
+ additionalProperties: false,
301
+ },
302
+ annotations: {
303
+ title: "Who am I",
304
+ readOnlyHint: true,
305
+ destructiveHint: false,
306
+ openWorldHint: false,
307
+ },
308
+ };
309
+ /**
310
+ * Build the identity echo for `whoami`. Never throws and never returns an error
311
+ * envelope — missing credentials are reported as `missing[]` notes, not failures.
312
+ */
313
+ export function handleWhoami(input) {
314
+ const missing = [];
315
+ let identity;
316
+ if (input.mode === "stdio") {
317
+ const keyType = classifyKeyType(input.apiKey);
318
+ if (keyType === "none")
319
+ missing.push("DOBBY_API_KEY (no key configured)");
320
+ if (!input.tenantId)
321
+ missing.push("DOBBY_TENANT_ID (no default workspace set)");
322
+ if (keyType === "pat") {
323
+ missing.push("gateway/execute tools disabled — a dobby_pat_ cannot mint a gateway key in local STDIO; use a gk_ key, or connect over the remote HTTP transport (mcp.dobby-ai.com)");
324
+ }
325
+ else if (!input.gatewayEnabled) {
326
+ missing.push("DOBBY_GATEWAY_KEY (gateway/execute tools disabled; a gk_ DOBBY_API_KEY also enables them)");
327
+ }
328
+ identity = {
329
+ transport: "stdio",
330
+ api_url: input.apiUrl,
331
+ key_type: keyType,
332
+ pat_present: keyType === "pat",
333
+ gateway_tools_enabled: input.gatewayEnabled,
334
+ tenant_id: input.tenantId ?? null,
335
+ organization_id: input.organizationId ?? null,
336
+ user_id: input.userId,
337
+ user_scoped: false,
338
+ // Local STDIO is full-access; scopes are not enforced by the server.
339
+ scopes: null,
340
+ is_super_admin: null,
341
+ };
342
+ }
343
+ else {
344
+ const token = input.authInfo?.token;
345
+ const extra = input.authInfo?.extra;
346
+ const userScoped = extra?.user_scoped === true;
347
+ const keyType = classifyKeyType(token);
348
+ if (!token)
349
+ missing.push("bearer token (no authenticated session)");
350
+ if (userScoped && !extra?.tenant_id) {
351
+ missing.push('tenant_id — this is a user-scoped connection: pass a "tenant_id" argument per call, or run list_my_tenants to see your options');
352
+ }
353
+ identity = {
354
+ transport: "http",
355
+ api_url: input.apiUrl,
356
+ key_type: keyType,
357
+ pat_present: keyType === "pat" || userScoped,
358
+ tenant_id: extra?.tenant_id ?? null,
359
+ organization_id: extra?.org_id ?? null,
360
+ user_id: extra?.user_id ?? null,
361
+ user_scoped: userScoped,
362
+ tenant_role: extra?.tenant_role ?? null,
363
+ org_role: extra?.org_role ?? null,
364
+ is_super_admin: extra?.is_super_admin === true,
365
+ scopes: input.authInfo?.scopes ?? [],
366
+ };
367
+ }
368
+ const summary = Object.entries(identity)
369
+ .map(([k, v]) => {
370
+ const rendered = v === null
371
+ ? "—"
372
+ : Array.isArray(v)
373
+ ? v.length
374
+ ? v.join(", ")
375
+ : "—"
376
+ : String(v);
377
+ return `• ${k}: ${rendered}`;
378
+ })
379
+ .join("\n");
380
+ const missingBlock = missing.length
381
+ ? `\n\nMissing / notes:\n${missing.map((m) => `⚠️ ${m}`).join("\n")}`
382
+ : "\n\n✅ Nothing missing for the current scope.";
383
+ return {
384
+ content: [
385
+ {
386
+ type: "text",
387
+ text: `Dobby MCP — current identity\n\n${summary}${missingBlock}\n\n` +
388
+ "```json\n" +
389
+ JSON.stringify({ ...identity, missing }, null, 2) +
390
+ "\n```",
391
+ },
392
+ ],
393
+ isError: false,
394
+ };
395
+ }
396
+ /**
397
+ * Synthetic `validate_credentials` tool. The sibling of `whoami`: where whoami
398
+ * ECHOES the session identity, this CONFIRMS the credentials actually work — it
399
+ * classifies the key, and in STDIO runs a live reachability probe so an
400
+ * onboarding agent can fail fast with one clear message instead of hunting
401
+ * through a scatter of downstream 401/404/500s. Like whoami it is a synthetic
402
+ * tool (registry stays 131), available in both transports, and NEVER throws —
403
+ * it reports what fails and the exact env var that supplies each credential.
404
+ * This is the MCP-callable counterpart of the `dobby-mcp --doctor` CLI (which an
405
+ * autonomous agent cannot run).
406
+ */
407
+ const VALIDATE_CREDENTIALS_TOOL = "validate_credentials";
408
+ export const VALIDATE_CREDENTIALS_TOOL_DEF = {
409
+ name: VALIDATE_CREDENTIALS_TOOL,
410
+ description: "Check whether your configured credentials actually work. Reports, per " +
411
+ "credential, present / format_ok / authorized (a live reachability probe in " +
412
+ "STDIO) plus the exact env var that supplies it. Complements whoami (which " +
413
+ "only echoes identity). Never throws — it reports what fails. Call this if a " +
414
+ "tool returns an auth error and you are unsure which credential is the problem.",
415
+ inputSchema: {
416
+ type: "object",
417
+ properties: {},
418
+ additionalProperties: false,
419
+ },
420
+ annotations: {
421
+ title: "Validate credentials",
422
+ readOnlyHint: true,
423
+ destructiveHint: false,
424
+ openWorldHint: false,
425
+ },
426
+ };
427
+ /**
428
+ * Run the credential validation. Never throws / never returns isError:true —
429
+ * failures are reported as `report.*.authorized=false` + `notes[]`.
430
+ */
431
+ export async function handleValidateCredentials(input) {
432
+ const report = {
433
+ transport: input.mode,
434
+ api_url: input.apiUrl,
435
+ };
436
+ const notes = [];
437
+ if (input.mode === "stdio") {
438
+ const keyType = classifyKeyType(input.apiKey);
439
+ const present = keyType !== "none";
440
+ const formatOk = keyType === "gateway" || keyType === "tenant" || keyType === "pat";
441
+ // Live authorization probe — only when we have both a key and a tenant.
442
+ // checkHealth never throws (it catches internally), but guard anyway.
443
+ let authorized = null;
444
+ if (present && input.tenantId) {
445
+ try {
446
+ const probe = createDobbyClient({
447
+ apiUrl: input.apiUrl,
448
+ apiKey: input.apiKey,
449
+ tenantId: input.tenantId,
450
+ });
451
+ authorized = await probe.checkHealth();
452
+ }
453
+ catch {
454
+ authorized = false;
455
+ }
456
+ }
457
+ if (!present) {
458
+ notes.push("Set DOBBY_API_KEY — a gk_user_/gk_svc_ gateway key, an sk_live_ tenant key, or (remote only) a dobby_pat_.");
459
+ }
460
+ else if (!formatOk) {
461
+ notes.push("DOBBY_API_KEY has an unrecognized prefix — expected gk_user_/gk_svc_/gk_tmp_, sk_live_, or dobby_pat_.");
462
+ }
463
+ if (present && !input.tenantId) {
464
+ notes.push("Set DOBBY_TENANT_ID to run the live authorization probe (tenant-scoped tools also need it).");
465
+ }
466
+ if (authorized === false) {
467
+ notes.push(`Could not reach/authorize against ${input.apiUrl} — check DOBBY_API_URL, the key value, and the tenant.`);
468
+ }
469
+ if (keyType === "pat") {
470
+ notes.push("A dobby_pat_ does NOT enable gateway/execute tools in local STDIO — use a gk_ key, or the remote HTTP transport.");
471
+ }
472
+ report.api_key = {
473
+ present,
474
+ key_type: keyType,
475
+ format_ok: formatOk,
476
+ authorized,
477
+ env_var: "DOBBY_API_KEY",
478
+ };
479
+ report.gateway_tools = {
480
+ enabled: input.gatewayEnabled,
481
+ source: input.gatewaySource,
482
+ env_var: "DOBBY_GATEWAY_KEY",
483
+ };
484
+ report.tenant = {
485
+ present: Boolean(input.tenantId),
486
+ env_var: "DOBBY_TENANT_ID",
487
+ };
488
+ report.ok = present && formatOk && authorized !== false;
489
+ }
490
+ else {
491
+ const token = input.authInfo?.token;
492
+ const extra = input.authInfo?.extra;
493
+ const keyType = classifyKeyType(token);
494
+ const present = Boolean(token);
495
+ // We only reach here AFTER the token passed OAuth introspection, so an
496
+ // authenticated session is itself proof of authorization.
497
+ const authorized = present;
498
+ if (!present) {
499
+ notes.push("No bearer token — authenticate with OAuth or a dobby_pat_ against mcp.dobby-ai.com.");
500
+ }
501
+ if (extra?.user_scoped && !extra?.tenant_id) {
502
+ notes.push('User-scoped session: pass a "tenant_id" per call, or run list_my_tenants to see your options.');
503
+ }
504
+ report.api_key = {
505
+ present,
506
+ key_type: keyType,
507
+ format_ok: present,
508
+ authorized,
509
+ env_var: "Authorization: Bearer",
510
+ };
511
+ report.gateway_tools = {
512
+ enabled: true,
513
+ source: "gk_tmp_ minted per request from your session",
514
+ };
515
+ report.tenant = {
516
+ present: Boolean(extra?.tenant_id),
517
+ user_scoped: extra?.user_scoped === true,
518
+ };
519
+ report.scopes = input.authInfo?.scopes ?? [];
520
+ report.ok = present;
521
+ }
522
+ const notesBlock = notes.length
523
+ ? `\n\nNotes:\n${notes.map((n) => `⚠️ ${n}`).join("\n")}`
524
+ : "\n\n✅ Credentials look good for the current scope.";
525
+ return {
526
+ content: [
527
+ {
528
+ type: "text",
529
+ text: "Dobby MCP — credential validation\n\n```json\n" +
530
+ JSON.stringify(report, null, 2) +
531
+ "\n```" +
532
+ notesBlock,
533
+ },
534
+ ],
535
+ isError: false,
536
+ };
537
+ }
538
+ /**
539
+ * ADR-020 user-scoped tool classification. A user-scoped PAT has no tenant/org
540
+ * pin, so an org-scoped tool must resolve its target ORG (not tenant) per call:
541
+ * - ORG_CLIENT_TOOLS read `client.organizationId` (memory stores / rubrics /
542
+ * orchestration configs). The caller passes an `org_id` argument, which the
543
+ * server sets on the per-request client; the org REST route then authorizes
544
+ * it live (withOrgAuth + tryPatOrgAuth → 403 for a non-member).
545
+ * - ORG_ARG_TOOLS already take an explicit `organization_id` in their own
546
+ * schema (the governance/policies tools), so they carry their own target —
547
+ * the user-scoped branch just must NOT demand a tenant_id for them.
548
+ * - PLATFORM_TOOLS are platform-scoped superadmin ops tools (health / status /
549
+ * issues / audit). They target NO tenant or org — they call the web app's
550
+ * /api/v1/internal/ops/* endpoints server-to-server (x-internal-secret), so
551
+ * the user-scoped branch must NOT demand a tenant_id for them either. Their
552
+ * visibility is still gated upstream by visibleTo({ isSuperAdmin }).
553
+ * Everything else is tenant-scoped and resolves from a per-call `tenant_id`.
554
+ */
555
+ const ORG_CLIENT_TOOLS = new Set([
556
+ "memory_store_list",
557
+ "memory_store_read",
558
+ "memory_store_write",
559
+ "memory_store_search",
560
+ "memory_store_delete",
561
+ "rubric_list",
562
+ "rubric_create",
563
+ "orchestration_config_list",
564
+ ]);
565
+ const ORG_ARG_TOOLS = new Set([
566
+ "export_policies",
567
+ "validate_policies",
568
+ "apply_guardrail",
569
+ "import_policies",
570
+ ]);
571
+ // Platform-scoped superadmin ops tools (capability-core `superAdminOnly`). They
572
+ // act on platform-global ops data via /api/v1/internal/ops/* (server-to-server
573
+ // x-internal-secret), NOT on any tenant/org, so a user-scoped (PAT) connection
574
+ // must NOT be asked for a tenant_id. Visibility is still gated upstream by
575
+ // visibleTo({ isSuperAdmin }); this only stops the tenant-required default from
576
+ // rejecting them once visible (ADR-020).
577
+ const PLATFORM_TOOLS = new Set([
578
+ "get_ops_health_summary",
579
+ "get_service_status",
580
+ "get_active_issues",
581
+ "acknowledge_ops_issue",
582
+ "get_ops_audit_log",
583
+ ]);
584
+ // ============================================
585
+ // Gateway Key Resolution
586
+ // ============================================
587
+ /**
588
+ * Resolve the gateway key to use for auth + audit logging.
589
+ * Priority: explicit DOBBY_GATEWAY_KEY > auto-detect gk_* in DOBBY_API_KEY > undefined (disabled)
590
+ */
591
+ export function resolveGatewayKey(opts) {
592
+ if (opts.gatewayKey)
593
+ return opts.gatewayKey;
594
+ if (opts.apiKey && opts.apiKey.startsWith("gk_"))
595
+ return opts.apiKey;
596
+ return undefined;
597
+ }
598
+ /**
599
+ * Create an MCP Server with all handlers registered.
600
+ *
601
+ * In STDIO mode: tenant context comes from config/env vars.
602
+ * In HTTP mode: tenant context comes from OAuth token (extra.authInfo.extra.tenant_id).
603
+ * The DobbyClient is created lazily per-request in HTTP mode.
604
+ */
605
+ export async function createServer(opts = { mode: "stdio" }) {
606
+ const apiUrl = opts.apiUrl || process.env.DOBBY_API_URL || "https://dobby-ai.com";
607
+ // STDIO gateway auth context — resolved BEFORE the client so gateway-scoped
608
+ // fetch helpers send the gk_* downstream via client.gatewayBearer() (they
609
+ // previously always sent apiKey, so DOBBY_GATEWAY_KEY + sk_live_ setups
610
+ // never reached gateway-guarded routes).
611
+ const gatewayKey = resolveGatewayKey({
612
+ gatewayKey: opts.gatewayKey,
613
+ apiKey: opts.apiKey,
614
+ });
615
+ const gatewayUrl = opts.gatewayUrl || apiUrl;
616
+ // In STDIO mode, create a shared client from env vars
617
+ let stdioClient;
618
+ if (opts.mode === "stdio") {
619
+ stdioClient = createDobbyClient({
620
+ apiUrl: opts.apiUrl,
621
+ apiKey: opts.apiKey,
622
+ tenantId: opts.tenantId,
623
+ gatewayKey,
624
+ });
625
+ }
626
+ // HTTP mode: per-SESSION memoized gateway-key resolution. createServer() is
627
+ // instantiated once per HTTP session (index.ts wires it as http-server.ts's
628
+ // createMcpServer factory, called on each new Mcp-Session-Id), so this
629
+ // closure caches ONE resolved gk_tmp_* key per session. Memoized on FIRST
630
+ // use: the OAuth token may rotate mid-session, but the resolved key stays
631
+ // valid — it is identity-bound (tenant+user), not token-bound.
632
+ //
633
+ // Tenant-pinned families (dobby_at_/sk_live_) use this single-key memo. A
634
+ // user-scoped PAT (ADR-020) has no pin — its gateway-scoped tools are
635
+ // tenant-scoped and carry a per-call tenant_id, so it memoizes a gk_tmp_ PER
636
+ // TARGET TENANT here (a session-wide key would be wrong for a cross-tenant
637
+ // caller).
638
+ let sessionGatewayKey;
639
+ const patGatewayKeyByTenant = new Map();
640
+ // Create MCP server
641
+ const server = new Server({
642
+ name: "dobby-mcp-server",
643
+ version: "1.1.0",
644
+ }, {
645
+ capabilities: {
646
+ tools: {},
647
+ resources: {},
648
+ prompts: {},
649
+ },
650
+ });
651
+ // STDIO user context
652
+ const userContext = {
653
+ userId: process.env.DOBBY_USER_ID || "mcp-user",
654
+ userName: process.env.DOBBY_USER_NAME || "MCP User",
655
+ };
656
+ // ============================================
657
+ // Tools Handler
658
+ // ============================================
659
+ server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
660
+ // HTTP mode: the introspection endpoint put the EFFECTIVE mcp scope on
661
+ // the token (plan-derived tier + read/execute) — filter the list by it.
662
+ // STDIO mode stays full access (local setup, key already proves tenant).
663
+ if (opts.mode === "http") {
664
+ const scopes = extra.authInfo?.scopes ?? [];
665
+ const rbac = extra.authInfo?.extra;
666
+ const tools = getToolDefinitions(resolveToolTier({ scopes }), {
667
+ readOnly: isReadOnlyScopes(scopes),
668
+ role: rbac?.tenant_role,
669
+ isSuperAdmin: rbac?.is_super_admin,
670
+ });
671
+ // whoami + validate_credentials are always available (identity echo +
672
+ // credential check) — appended after the tier-filtered list so they show
673
+ // for every token family.
674
+ tools.push(WHOAMI_TOOL_DEF);
675
+ tools.push(VALIDATE_CREDENTIALS_TOOL_DEF);
676
+ // ADR-020: a user-scoped session also gets the synthetic discovery tools
677
+ // so a client can enumerate the tenants + organizations it may act in.
678
+ // Append-only — a tenant-pinned session (and the byte-identical golden
679
+ // tests) is unchanged.
680
+ if (rbac?.user_scoped) {
681
+ tools.push(USER_SCOPED_TENANTS_TOOL_DEF);
682
+ tools.push(USER_SCOPED_ORGS_TOOL_DEF);
683
+ }
684
+ return { tools };
685
+ }
686
+ return {
687
+ tools: [
688
+ ...getToolDefinitions("enterprise"),
689
+ WHOAMI_TOOL_DEF,
690
+ VALIDATE_CREDENTIALS_TOOL_DEF,
691
+ ],
692
+ };
693
+ });
694
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
695
+ const { name, arguments: args } = request.params;
696
+ try {
697
+ // whoami — always available, never gated, never errors. Answer identity
698
+ // BEFORE any tier/tenant resolution so a caller can orient even when its
699
+ // credentials are missing or ambiguous.
700
+ if (name === WHOAMI_TOOL) {
701
+ return opts.mode === "http"
702
+ ? handleWhoami({
703
+ mode: "http",
704
+ apiUrl,
705
+ authInfo: extra.authInfo,
706
+ })
707
+ : handleWhoami({
708
+ mode: "stdio",
709
+ apiUrl,
710
+ apiKey: opts.apiKey,
711
+ tenantId: opts.tenantId ?? process.env.DOBBY_TENANT_ID,
712
+ organizationId: process.env.DOBBY_ORGANIZATION_ID,
713
+ gatewayEnabled: Boolean(gatewayKey),
714
+ userId: userContext.userId,
715
+ });
716
+ }
717
+ // validate_credentials — like whoami, always available and never gated.
718
+ // In STDIO it runs a live reachability probe, so it is async.
719
+ if (name === VALIDATE_CREDENTIALS_TOOL) {
720
+ return opts.mode === "http"
721
+ ? await handleValidateCredentials({
722
+ mode: "http",
723
+ apiUrl,
724
+ authInfo: extra.authInfo,
725
+ })
726
+ : await handleValidateCredentials({
727
+ mode: "stdio",
728
+ apiUrl,
729
+ apiKey: opts.apiKey,
730
+ tenantId: opts.tenantId ?? process.env.DOBBY_TENANT_ID,
731
+ gatewayEnabled: Boolean(gatewayKey),
732
+ gatewaySource: opts.gatewayKey
733
+ ? "explicit"
734
+ : opts.apiKey?.startsWith("gk_")
735
+ ? "api_key"
736
+ : "none",
737
+ });
738
+ }
739
+ // HTTP mode: enforce tier + read-only on the CALL, not just the list —
740
+ // list filtering alone is cosmetic (clients can call any tool by name).
741
+ if (opts.mode === "http") {
742
+ const rbac = extra.authInfo?.extra;
743
+ const denial = getToolAccessDenial(name, {
744
+ scopes: extra.authInfo?.scopes ?? [],
745
+ role: rbac?.tenant_role,
746
+ isSuperAdmin: rbac?.is_super_admin,
747
+ });
748
+ if (denial) {
749
+ return {
750
+ content: [{ type: "text", text: denial }],
751
+ isError: true,
752
+ };
753
+ }
754
+ }
755
+ let client;
756
+ let userId;
757
+ let userName;
758
+ let tenantId;
759
+ let region;
760
+ let gwKey;
761
+ let gwUrl;
762
+ // ADR-019/020 identity, threaded into the CapabilityContext so handlers
763
+ // can authorize per call (Wave 2/3). Empty in STDIO mode (local, full
764
+ // access). For a user-scoped PAT tenant_role/org_role are the AGGREGATE role.
765
+ let ctxOrgId;
766
+ let ctxScopes;
767
+ let ctxTenantRole;
768
+ let ctxOrgRole;
769
+ let ctxIsSuperAdmin = false;
770
+ if (opts.mode === "http" && extra.authInfo) {
771
+ // HTTP mode — resolve context from the introspected token.
772
+ const tokenExtra = extra.authInfo.extra;
773
+ const token = extra.authInfo.token;
774
+ const isGatewayKeyToken = token.startsWith("gk_");
775
+ const userScoped = tokenExtra?.user_scoped === true;
776
+ const tokenUserId = tokenExtra?.user_id || "oauth-user";
777
+ // Identity carried from introspection (ADR-019 P2 / ADR-020). For a
778
+ // user-scoped PAT, tenant_role/org_role are the AGGREGATE (max) role.
779
+ ctxOrgId = tokenExtra?.org_id ?? undefined;
780
+ ctxScopes = extra.authInfo.scopes ?? [];
781
+ ctxTenantRole = tokenExtra?.tenant_role;
782
+ ctxOrgRole = tokenExtra?.org_role;
783
+ ctxIsSuperAdmin = tokenExtra?.is_super_admin === true;
784
+ // User-scoped discovery tools (ADR-020): not registered capabilities,
785
+ // so intercept them before any tenant/org resolution or client build.
786
+ // They fetch directly (apiUrl + token) and need no per-request client.
787
+ if (userScoped && name === USER_SCOPED_TENANTS_TOOL) {
788
+ return await handleListMyTenants(apiUrl, token);
789
+ }
790
+ if (userScoped && name === USER_SCOPED_ORGS_TOOL) {
791
+ return await handleListMyOrgs(apiUrl, token);
792
+ }
793
+ // Resolve the TARGET tenant/org.
794
+ // - Pinned families (dobby_at_/sk_live_/gk_): the token's single tenant
795
+ // pin (org-scoped tools resolve their org downstream from the pin).
796
+ // - User-scoped PAT (ADR-020): the target comes from a per-call
797
+ // argument, authorized LIVE by the web REST layer (never a frozen
798
+ // cross-tenant/cross-org grant):
799
+ // · ORG_CLIENT_TOOLS → an `org_id` arg, set on the per-request
800
+ // client so the handler's /organizations/{org}/… call is
801
+ // authorized by withOrgAuth + tryPatOrgAuth (403 for a non-member).
802
+ // · ORG_ARG_TOOLS → the tool's own `organization_id` arg carries
803
+ // the target; nothing to require here.
804
+ // · everything else is tenant-scoped → a `tenant_id` arg
805
+ // (withTenantAuth + tryPatAuth → 403 for a non-member).
806
+ let targetTenantId;
807
+ let targetOrgId;
808
+ if (userScoped) {
809
+ if (ORG_CLIENT_TOOLS.has(name)) {
810
+ const rawOrgArg = args
811
+ ?.org_id;
812
+ const argOrg = typeof rawOrgArg === "string" ? rawOrgArg.trim() : "";
813
+ if (!argOrg) {
814
+ return needsSelectionResult("organization", "org_id", USER_SCOPED_ORGS_TOOL);
815
+ }
816
+ targetOrgId = argOrg;
817
+ }
818
+ else if (ORG_ARG_TOOLS.has(name)) {
819
+ // The tool carries its own `organization_id`; its org REST route
820
+ // authorizes the PAT live against it. No tenant/org to resolve here.
821
+ }
822
+ else if (PLATFORM_TOOLS.has(name)) {
823
+ // Platform-scoped ops tool: targets no tenant/org (it hits
824
+ // /api/v1/internal/ops/* server-to-server). Nothing to resolve here;
825
+ // visibility is already gated by visibleTo({ isSuperAdmin }).
826
+ }
827
+ else {
828
+ const rawTenantArg = args
829
+ ?.tenant_id;
830
+ const argTenant = typeof rawTenantArg === "string" ? rawTenantArg.trim() : "";
831
+ if (!argTenant) {
832
+ // Short-prompt UX: if the caller belongs to exactly ONE tenant,
833
+ // auto-select it silently — no needs_selection round-trip, so
834
+ // "create an agent named X" works from a single prompt. Ambiguous
835
+ // cases (zero or many tenants) still return the structured
836
+ // needs_selection envelope so the client asks which workspace.
837
+ const sole = await resolveSoleTenant(apiUrl, token);
838
+ if (!sole) {
839
+ return needsSelectionResult("tenant", "tenant_id", USER_SCOPED_TENANTS_TOOL);
840
+ }
841
+ targetTenantId = sole;
842
+ }
843
+ else {
844
+ targetTenantId = argTenant || undefined;
845
+ }
846
+ }
847
+ }
848
+ else {
849
+ targetTenantId = tokenExtra?.tenant_id;
850
+ if (!targetTenantId) {
851
+ return {
852
+ content: [
853
+ {
854
+ type: "text",
855
+ text: "Error: OAuth token missing tenant_id",
856
+ },
857
+ ],
858
+ isError: true,
859
+ };
860
+ }
861
+ // Tenant-pinned families (dobby_at_ / sk_live_ / gk_) carry their org
862
+ // in the token — wire it onto the per-request client so ORG-scoped
863
+ // tools (memory_store_*, rubric_*, orchestration_config_list) work.
864
+ // Those handlers read client.organizationId; without this a
865
+ // tenant-pinned OAuth/sk_live_ session got "No organization context"
866
+ // for every org-scoped tool even though whoami showed the org
867
+ // (customer hit 2026-07-20). user-scoped PATs resolve org per-call
868
+ // above, so this only fills the pinned case.
869
+ targetOrgId = tokenExtra?.org_id ?? undefined;
870
+ }
871
+ // Reflect the per-call org target in the identity context too, so the
872
+ // CapabilityContext.orgId a handler sees matches the org it acts on.
873
+ if (targetOrgId)
874
+ ctxOrgId = targetOrgId;
875
+ // Create per-request client using the session bearer as the API key
876
+ // (tenant-scoped tools send it as X-API-Key, which the web app accepts
877
+ // for all families: dobby_at_ / sk_live_ / gk_ / dobby_pat_).
878
+ //
879
+ // Gateway-scoped tools authenticate downstream with a gk_* key
880
+ // (authenticateGatewayRequest hard-requires one). The provider that
881
+ // supplies it differs by session family:
882
+ // - gk_* Bearer session: the token IS a gateway key — no provider;
883
+ // gatewayBearer() falls back to apiKey and sends it downstream.
884
+ // - dobby_at_ / sk_live_ session: a LAZY provider exchanges the token
885
+ // for a tenant+user-bound gk_tmp_* via the web app's internal
886
+ // endpoint (tenant derived from the token), memoized once per session.
887
+ // - user-scoped PAT (ADR-020): gateway-scoped tools are tenant-scoped
888
+ // and carry a per-call tenant_id, so the provider resolves a gk_tmp_*
889
+ // for THAT tenant (memoized per tenant). The internal endpoint
890
+ // authorizes the PAT's LIVE membership in the tenant before minting,
891
+ // so no frozen cross-tenant grant is possible. Org-scoped tools have
892
+ // no tenant → no gateway provider (they are not gateway-scoped).
893
+ let getGatewayKey;
894
+ if (isGatewayKeyToken) {
895
+ getGatewayKey = undefined;
896
+ }
897
+ else if (userScoped) {
898
+ if (targetTenantId) {
899
+ const t = targetTenantId;
900
+ getGatewayKey = () => {
901
+ let p = patGatewayKeyByTenant.get(t);
902
+ if (!p) {
903
+ p = resolveSessionGatewayKey({ apiUrl, token, tenantId: t });
904
+ patGatewayKeyByTenant.set(t, p);
905
+ }
906
+ return p;
907
+ };
908
+ }
909
+ }
910
+ else {
911
+ getGatewayKey = () => (sessionGatewayKey ??= resolveSessionGatewayKey({ apiUrl, token }));
912
+ }
913
+ client = new DobbyClient({
914
+ apiUrl,
915
+ apiKey: token,
916
+ tenantId: targetTenantId ?? "",
917
+ // ADR-020 org-scoped tools (ORG_CLIENT_TOOLS): the per-call `org_id`
918
+ // becomes the client's org context, which their handlers read via
919
+ // client.organizationId to build /organizations/{org}/… requests.
920
+ organizationId: targetOrgId,
921
+ ...(getGatewayKey ? { getGatewayKey } : {}),
922
+ });
923
+ userId = tokenUserId;
924
+ userName = "OAuth User";
925
+ tenantId = targetTenantId;
926
+ region = undefined; // resolved by web app from tenant
927
+ // Gateway pre-check key (checkGatewayAuth → POST /api/v1/gateway/mcp/tools):
928
+ // - gk_* Bearer session: forward the token — restores the pre-check +
929
+ // gateway metering for genuine gateway-key sessions (commit 157db598b
930
+ // disabled it for ALL families; only OAuth/sk_live_ needed the skip).
931
+ // - dobby_at_ / sk_live_ / user-scoped session: leave it undefined —
932
+ // the pre-check authenticates a gk_* GATEWAY key, and forwarding a
933
+ // non-gk_ token made the gateway 404 → every tool denied "Not Found"
934
+ // (found 2026-07-12 on the live claude.ai connector). Unset correctly
935
+ // short-circuits the pre-check to allowed; the REAL gate for
936
+ // gateway-scoped endpoints is downstream authenticateGatewayRequest,
937
+ // reached with the resolved gk_tmp_* (client.gatewayBearer()).
938
+ gwKey = isGatewayKeyToken ? token : undefined;
939
+ gwUrl = apiUrl;
940
+ }
941
+ else {
942
+ // STDIO mode — use env vars
943
+ client = stdioClient;
944
+ userId = userContext.userId;
945
+ userName = userContext.userName;
946
+ tenantId = client.tenantId;
947
+ region = process.env.DOBBY_REGION;
948
+ gwKey = gatewayKey;
949
+ gwUrl = gatewayUrl;
950
+ }
951
+ const result = await handleToolCall(name, args || {}, {
952
+ client,
953
+ userId,
954
+ userName,
955
+ gatewayKey: gwKey,
956
+ gatewayUrl: gwUrl,
957
+ tenantId,
958
+ region,
959
+ orgId: ctxOrgId,
960
+ scopes: ctxScopes,
961
+ tenantRole: ctxTenantRole,
962
+ orgRole: ctxOrgRole,
963
+ isSuperAdmin: ctxIsSuperAdmin,
964
+ });
965
+ return result;
966
+ }
967
+ catch (error) {
968
+ return {
969
+ content: [
970
+ {
971
+ type: "text",
972
+ text: `Error: ${error.message}`,
973
+ },
974
+ ],
975
+ isError: true,
976
+ };
977
+ }
978
+ });
979
+ // ============================================
980
+ // Resources Handler
981
+ // ============================================
982
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
983
+ return {
984
+ resources: resourceDefinitions,
985
+ resourceTemplates: resourceTemplates,
986
+ };
987
+ });
988
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
989
+ const { uri } = request.params;
990
+ const client = stdioClient || new DobbyClient({ apiUrl, apiKey: "", tenantId: "" });
991
+ try {
992
+ return await handleResourceRead(uri, client);
993
+ }
994
+ catch (error) {
995
+ throw new Error(`Failed to read resource ${uri}: ${error.message}`);
996
+ }
997
+ });
998
+ // ============================================
999
+ // Prompts Handler
1000
+ // ============================================
1001
+ server.setRequestHandler(ListPromptsRequestSchema, async () => {
1002
+ return {
1003
+ prompts: promptDefinitions,
1004
+ };
1005
+ });
1006
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
1007
+ const { name, arguments: args } = request.params;
1008
+ try {
1009
+ return handlePromptGet(name, args || {});
1010
+ }
1011
+ catch (error) {
1012
+ throw new Error(`Failed to get prompt ${name}: ${error.message}`);
1013
+ }
1014
+ });
1015
+ return server;
1016
+ }
1017
+ //# sourceMappingURL=server.js.map