@studio-foundation/runner 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/ARCHITECTURE.md +53 -0
  2. package/LICENSE +663 -0
  3. package/README.md +125 -0
  4. package/configs/agents/analyst.agent.yaml +31 -0
  5. package/configs/agents/code-generator.agent.yaml +31 -0
  6. package/configs/agents/generic.agent.yaml +23 -0
  7. package/dist/__tests__/script-executor.test.d.ts +2 -0
  8. package/dist/__tests__/script-executor.test.d.ts.map +1 -0
  9. package/dist/__tests__/script-executor.test.js +173 -0
  10. package/dist/__tests__/script-executor.test.js.map +1 -0
  11. package/dist/index.d.ts +38 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +76 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/integrations/integration-loader.d.ts +20 -0
  16. package/dist/integrations/integration-loader.d.ts.map +1 -0
  17. package/dist/integrations/integration-loader.js +71 -0
  18. package/dist/integrations/integration-loader.js.map +1 -0
  19. package/dist/integrations/integration-loader.test.d.ts +2 -0
  20. package/dist/integrations/integration-loader.test.d.ts.map +1 -0
  21. package/dist/integrations/integration-loader.test.js +77 -0
  22. package/dist/integrations/integration-loader.test.js.map +1 -0
  23. package/dist/middleware/anonymization.d.ts +22 -0
  24. package/dist/middleware/anonymization.d.ts.map +1 -0
  25. package/dist/middleware/anonymization.js +37 -0
  26. package/dist/middleware/anonymization.js.map +1 -0
  27. package/dist/plugins/index.d.ts +5 -0
  28. package/dist/plugins/index.d.ts.map +1 -0
  29. package/dist/plugins/index.js +10 -0
  30. package/dist/plugins/index.js.map +1 -0
  31. package/dist/plugins/mcp-client.d.ts +19 -0
  32. package/dist/plugins/mcp-client.d.ts.map +1 -0
  33. package/dist/plugins/mcp-client.js +126 -0
  34. package/dist/plugins/mcp-client.js.map +1 -0
  35. package/dist/plugins/mcp-client.test.d.ts +2 -0
  36. package/dist/plugins/mcp-client.test.d.ts.map +1 -0
  37. package/dist/plugins/mcp-client.test.js +160 -0
  38. package/dist/plugins/mcp-client.test.js.map +1 -0
  39. package/dist/plugins/oauth-provider.d.ts +27 -0
  40. package/dist/plugins/oauth-provider.d.ts.map +1 -0
  41. package/dist/plugins/oauth-provider.js +140 -0
  42. package/dist/plugins/oauth-provider.js.map +1 -0
  43. package/dist/plugins/oauth-provider.test.d.ts +2 -0
  44. package/dist/plugins/oauth-provider.test.d.ts.map +1 -0
  45. package/dist/plugins/oauth-provider.test.js +144 -0
  46. package/dist/plugins/oauth-provider.test.js.map +1 -0
  47. package/dist/plugins/plugin-loader.d.ts +29 -0
  48. package/dist/plugins/plugin-loader.d.ts.map +1 -0
  49. package/dist/plugins/plugin-loader.js +71 -0
  50. package/dist/plugins/plugin-loader.js.map +1 -0
  51. package/dist/plugins/plugin-loader.test.d.ts +2 -0
  52. package/dist/plugins/plugin-loader.test.d.ts.map +1 -0
  53. package/dist/plugins/plugin-loader.test.js +87 -0
  54. package/dist/plugins/plugin-loader.test.js.map +1 -0
  55. package/dist/prompt-builder.d.ts +47 -0
  56. package/dist/prompt-builder.d.ts.map +1 -0
  57. package/dist/prompt-builder.js +263 -0
  58. package/dist/prompt-builder.js.map +1 -0
  59. package/dist/prompt-builder.test.d.ts +2 -0
  60. package/dist/prompt-builder.test.d.ts.map +1 -0
  61. package/dist/prompt-builder.test.js +154 -0
  62. package/dist/prompt-builder.test.js.map +1 -0
  63. package/dist/providers/anthropic.d.ts +14 -0
  64. package/dist/providers/anthropic.d.ts.map +1 -0
  65. package/dist/providers/anthropic.js +118 -0
  66. package/dist/providers/anthropic.js.map +1 -0
  67. package/dist/providers/anthropic.test.d.ts +2 -0
  68. package/dist/providers/anthropic.test.d.ts.map +1 -0
  69. package/dist/providers/anthropic.test.js +76 -0
  70. package/dist/providers/anthropic.test.js.map +1 -0
  71. package/dist/providers/mock.d.ts +17 -0
  72. package/dist/providers/mock.d.ts.map +1 -0
  73. package/dist/providers/mock.js +39 -0
  74. package/dist/providers/mock.js.map +1 -0
  75. package/dist/providers/ollama.d.ts +18 -0
  76. package/dist/providers/ollama.d.ts.map +1 -0
  77. package/dist/providers/ollama.js +155 -0
  78. package/dist/providers/ollama.js.map +1 -0
  79. package/dist/providers/ollama.test.d.ts +2 -0
  80. package/dist/providers/ollama.test.d.ts.map +1 -0
  81. package/dist/providers/ollama.test.js +123 -0
  82. package/dist/providers/ollama.test.js.map +1 -0
  83. package/dist/providers/openai-responses.d.ts +18 -0
  84. package/dist/providers/openai-responses.d.ts.map +1 -0
  85. package/dist/providers/openai-responses.js +175 -0
  86. package/dist/providers/openai-responses.js.map +1 -0
  87. package/dist/providers/openai.d.ts +16 -0
  88. package/dist/providers/openai.d.ts.map +1 -0
  89. package/dist/providers/openai.js +132 -0
  90. package/dist/providers/openai.js.map +1 -0
  91. package/dist/providers/openai.test.d.ts +2 -0
  92. package/dist/providers/openai.test.d.ts.map +1 -0
  93. package/dist/providers/openai.test.js +51 -0
  94. package/dist/providers/openai.test.js.map +1 -0
  95. package/dist/providers/provider.d.ts +42 -0
  96. package/dist/providers/provider.d.ts.map +1 -0
  97. package/dist/providers/provider.js +10 -0
  98. package/dist/providers/provider.js.map +1 -0
  99. package/dist/providers/registry.d.ts +42 -0
  100. package/dist/providers/registry.d.ts.map +1 -0
  101. package/dist/providers/registry.js +65 -0
  102. package/dist/providers/registry.js.map +1 -0
  103. package/dist/runner.d.ts +46 -0
  104. package/dist/runner.d.ts.map +1 -0
  105. package/dist/runner.js +324 -0
  106. package/dist/runner.js.map +1 -0
  107. package/dist/runner.test.d.ts +2 -0
  108. package/dist/runner.test.d.ts.map +1 -0
  109. package/dist/runner.test.js +280 -0
  110. package/dist/runner.test.js.map +1 -0
  111. package/dist/script-executor.d.ts +11 -0
  112. package/dist/script-executor.d.ts.map +1 -0
  113. package/dist/script-executor.js +85 -0
  114. package/dist/script-executor.js.map +1 -0
  115. package/dist/tools/builtin/git.d.ts +6 -0
  116. package/dist/tools/builtin/git.d.ts.map +1 -0
  117. package/dist/tools/builtin/git.js +294 -0
  118. package/dist/tools/builtin/git.js.map +1 -0
  119. package/dist/tools/builtin/patch.d.ts +6 -0
  120. package/dist/tools/builtin/patch.d.ts.map +1 -0
  121. package/dist/tools/builtin/patch.js +238 -0
  122. package/dist/tools/builtin/patch.js.map +1 -0
  123. package/dist/tools/builtin/repo-manager.d.ts +6 -0
  124. package/dist/tools/builtin/repo-manager.d.ts.map +1 -0
  125. package/dist/tools/builtin/repo-manager.js +171 -0
  126. package/dist/tools/builtin/repo-manager.js.map +1 -0
  127. package/dist/tools/builtin/search.d.ts +6 -0
  128. package/dist/tools/builtin/search.d.ts.map +1 -0
  129. package/dist/tools/builtin/search.js +104 -0
  130. package/dist/tools/builtin/search.js.map +1 -0
  131. package/dist/tools/builtin/shell.d.ts +6 -0
  132. package/dist/tools/builtin/shell.d.ts.map +1 -0
  133. package/dist/tools/builtin/shell.js +78 -0
  134. package/dist/tools/builtin/shell.js.map +1 -0
  135. package/dist/tools/builtin/studio-run.d.ts +12 -0
  136. package/dist/tools/builtin/studio-run.d.ts.map +1 -0
  137. package/dist/tools/builtin/studio-run.js +61 -0
  138. package/dist/tools/builtin/studio-run.js.map +1 -0
  139. package/dist/tools/builtin/web-search.d.ts +7 -0
  140. package/dist/tools/builtin/web-search.d.ts.map +1 -0
  141. package/dist/tools/builtin/web-search.js +94 -0
  142. package/dist/tools/builtin/web-search.js.map +1 -0
  143. package/dist/tools/builtin/web-search.test.d.ts +5 -0
  144. package/dist/tools/builtin/web-search.test.d.ts.map +1 -0
  145. package/dist/tools/builtin/web-search.test.js +90 -0
  146. package/dist/tools/builtin/web-search.test.js.map +1 -0
  147. package/dist/tools/errors.d.ts +4 -0
  148. package/dist/tools/errors.d.ts.map +1 -0
  149. package/dist/tools/errors.js +11 -0
  150. package/dist/tools/errors.js.map +1 -0
  151. package/dist/tools/errors.test.d.ts +2 -0
  152. package/dist/tools/errors.test.d.ts.map +1 -0
  153. package/dist/tools/errors.test.js +14 -0
  154. package/dist/tools/errors.test.js.map +1 -0
  155. package/dist/tools/plugin-loader.d.ts +31 -0
  156. package/dist/tools/plugin-loader.d.ts.map +1 -0
  157. package/dist/tools/plugin-loader.js +183 -0
  158. package/dist/tools/plugin-loader.js.map +1 -0
  159. package/dist/tools/plugin-loader.test.d.ts +2 -0
  160. package/dist/tools/plugin-loader.test.d.ts.map +1 -0
  161. package/dist/tools/plugin-loader.test.js +116 -0
  162. package/dist/tools/plugin-loader.test.js.map +1 -0
  163. package/dist/tools/skills/skill-loader.d.ts +23 -0
  164. package/dist/tools/skills/skill-loader.d.ts.map +1 -0
  165. package/dist/tools/skills/skill-loader.js +58 -0
  166. package/dist/tools/skills/skill-loader.js.map +1 -0
  167. package/dist/tools/skills/skill-loader.test.d.ts +2 -0
  168. package/dist/tools/skills/skill-loader.test.d.ts.map +1 -0
  169. package/dist/tools/skills/skill-loader.test.js +92 -0
  170. package/dist/tools/skills/skill-loader.test.js.map +1 -0
  171. package/dist/tools/tool-executor.d.ts +12 -0
  172. package/dist/tools/tool-executor.d.ts.map +1 -0
  173. package/dist/tools/tool-executor.js +76 -0
  174. package/dist/tools/tool-executor.js.map +1 -0
  175. package/dist/tools/tool-executor.test.d.ts +2 -0
  176. package/dist/tools/tool-executor.test.d.ts.map +1 -0
  177. package/dist/tools/tool-executor.test.js +83 -0
  178. package/dist/tools/tool-executor.test.js.map +1 -0
  179. package/dist/tools/tool-registry.d.ts +65 -0
  180. package/dist/tools/tool-registry.d.ts.map +1 -0
  181. package/dist/tools/tool-registry.js +112 -0
  182. package/dist/tools/tool-registry.js.map +1 -0
  183. package/dist/tools/yaml-executor.d.ts +22 -0
  184. package/dist/tools/yaml-executor.d.ts.map +1 -0
  185. package/dist/tools/yaml-executor.js +80 -0
  186. package/dist/tools/yaml-executor.js.map +1 -0
  187. package/dist/utils/race-signal.d.ts +6 -0
  188. package/dist/utils/race-signal.d.ts.map +1 -0
  189. package/dist/utils/race-signal.js +21 -0
  190. package/dist/utils/race-signal.js.map +1 -0
  191. package/dist/utils/race-signal.test.d.ts +2 -0
  192. package/dist/utils/race-signal.test.d.ts.map +1 -0
  193. package/dist/utils/race-signal.test.js +48 -0
  194. package/dist/utils/race-signal.test.js.map +1 -0
  195. package/package.json +36 -0
  196. package/src/__tests__/script-executor.test.ts +180 -0
  197. package/src/index.ts +68 -0
  198. package/src/integrations/integration-loader.test.ts +88 -0
  199. package/src/integrations/integration-loader.ts +68 -0
  200. package/src/middleware/anonymization.ts +38 -0
  201. package/src/plugins/index.ts +4 -0
  202. package/src/plugins/mcp-client.test.ts +148 -0
  203. package/src/plugins/mcp-client.ts +128 -0
  204. package/src/plugins/oauth-provider.test.ts +167 -0
  205. package/src/plugins/oauth-provider.ts +175 -0
  206. package/src/plugins/plugin-loader.test.ts +114 -0
  207. package/src/plugins/plugin-loader.ts +90 -0
  208. package/src/prompt-builder.test.ts +167 -0
  209. package/src/prompt-builder.ts +332 -0
  210. package/src/providers/anthropic.test.ts +101 -0
  211. package/src/providers/anthropic.ts +135 -0
  212. package/src/providers/mock.ts +57 -0
  213. package/src/providers/ollama.test.ts +166 -0
  214. package/src/providers/ollama.ts +152 -0
  215. package/src/providers/openai-responses.ts +212 -0
  216. package/src/providers/openai.test.ts +67 -0
  217. package/src/providers/openai.ts +139 -0
  218. package/src/providers/provider.ts +54 -0
  219. package/src/providers/registry.ts +77 -0
  220. package/src/runner.test.ts +343 -0
  221. package/src/runner.ts +396 -0
  222. package/src/script-executor.ts +107 -0
  223. package/src/tools/builtin/git.ts +311 -0
  224. package/src/tools/builtin/patch.ts +257 -0
  225. package/src/tools/builtin/repo-manager.ts +142 -0
  226. package/src/tools/builtin/search.ts +108 -0
  227. package/src/tools/builtin/shell.ts +82 -0
  228. package/src/tools/builtin/studio-run.ts +73 -0
  229. package/src/tools/builtin/web-search.test.ts +122 -0
  230. package/src/tools/builtin/web-search.ts +101 -0
  231. package/src/tools/errors.test.ts +12 -0
  232. package/src/tools/errors.ts +6 -0
  233. package/src/tools/plugin-loader.test.ts +130 -0
  234. package/src/tools/plugin-loader.ts +203 -0
  235. package/src/tools/skills/README.md +49 -0
  236. package/src/tools/skills/skill-loader.test.ts +106 -0
  237. package/src/tools/skills/skill-loader.ts +62 -0
  238. package/src/tools/tool-executor.test.ts +88 -0
  239. package/src/tools/tool-executor.ts +84 -0
  240. package/src/tools/tool-registry.ts +130 -0
  241. package/src/tools/yaml-executor.ts +120 -0
  242. package/src/utils/race-signal.test.ts +50 -0
  243. package/src/utils/race-signal.ts +17 -0
  244. package/templates/integrations/linear.integration.yaml +35 -0
  245. package/templates/integrations/slack.integration.yaml +22 -0
  246. package/templates/integrations/webhook.integration.yaml +17 -0
  247. package/templates/tools/git.tool.yaml +80 -0
  248. package/templates/tools/repo-manager.tool.yaml +64 -0
  249. package/templates/tools/search.tool.yaml +22 -0
  250. package/templates/tools/shell.tool.yaml +19 -0
  251. package/templates/tools/web-search.tool.yaml +24 -0
  252. package/tests/anonymization-middleware.test.ts +61 -0
  253. package/tests/anthropic.test.ts +87 -0
  254. package/tests/apply-patch.test.ts +355 -0
  255. package/tests/fixtures/tools/test-builtin.tool.yaml +14 -0
  256. package/tests/fixtures/tools/test-shell.tool.yaml +19 -0
  257. package/tests/mock-provider.test.ts +104 -0
  258. package/tests/openai.test.ts +72 -0
  259. package/tests/plugin-loader.test.ts +54 -0
  260. package/tests/prompt-builder.test.ts +468 -0
  261. package/tests/runner-anonymization.test.ts +89 -0
  262. package/tests/runner.test.ts +885 -0
  263. package/tests/studio-run.test.ts +94 -0
  264. package/tests/tool-executor.test.ts +115 -0
  265. package/tests/tool-registry.test.ts +84 -0
  266. package/tests/yaml-executor.test.ts +76 -0
  267. package/tsconfig.json +20 -0
  268. package/vitest.config.ts +7 -0
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { MCPClient } from './mcp-client.js';
3
+
4
+ describe('MCPClient', () => {
5
+ it('generates correct tool prefix', () => {
6
+ const client = new MCPClient('code-review', 'github', {
7
+ command: 'npx',
8
+ args: ['-y', '@mcp/server-github'],
9
+ });
10
+ expect(client.toolPrefix()).toBe('code-review-github');
11
+ });
12
+
13
+ it('resolves ${ENV_VAR} in env config', () => {
14
+ process.env.TEST_TOKEN = 'secret-123';
15
+ const client = new MCPClient('myplugin', 'myserver', {
16
+ command: 'npx',
17
+ env: { MY_TOKEN: '${TEST_TOKEN}' },
18
+ });
19
+ const resolved = client.resolveEnv({ MY_TOKEN: '${TEST_TOKEN}' });
20
+ expect(resolved.MY_TOKEN).toBe('secret-123');
21
+ delete process.env.TEST_TOKEN;
22
+ });
23
+
24
+ it('leaves env vars without substitution syntax as-is', () => {
25
+ const client = new MCPClient('p', 's', { command: 'cmd' });
26
+ const resolved = client.resolveEnv({ KEY: 'literal-value' });
27
+ expect(resolved.KEY).toBe('literal-value');
28
+ });
29
+
30
+ it('uses HTTP transport for type:http — invalid url throws TypeError', () => {
31
+ // StreamableHTTPClientTransport validates the URL in its constructor via `new URL(url)`.
32
+ // If the old StdioClientTransport code path runs instead, no error is thrown (command
33
+ // is only validated at start() time). So a throw here proves the right transport is used.
34
+ expect(() => {
35
+ new MCPClient('linear', 'linear', { type: 'http', url: 'not-a-valid-url' });
36
+ }).toThrow(TypeError);
37
+ });
38
+
39
+ it('constructs successfully with a valid HTTP url', () => {
40
+ expect(() => {
41
+ new MCPClient('linear', 'linear', { type: 'http', url: 'https://mcp.linear.app/sse' });
42
+ }).not.toThrow();
43
+ });
44
+ });
45
+
46
+ describe('MCPClient — OAuth constructor', () => {
47
+ it('creates an oauthProvider when auth.type is oauth', () => {
48
+ const client = new MCPClient('linear', 'linear', {
49
+ type: 'http',
50
+ url: 'https://mcp.linear.app/mcp',
51
+ auth: { type: 'oauth' },
52
+ });
53
+ expect(client.oauthProvider).toBeDefined();
54
+ });
55
+
56
+ it('does not create oauthProvider for plain HTTP server', () => {
57
+ const client = new MCPClient('github', 'github', {
58
+ type: 'http',
59
+ url: 'https://mcp.github.com/mcp',
60
+ headers: { Authorization: 'Bearer tok' },
61
+ });
62
+ expect(client.oauthProvider).toBeUndefined();
63
+ });
64
+
65
+ it('does not create oauthProvider for stdio server', () => {
66
+ const client = new MCPClient('myplugin', 'myserver', { command: 'cmd' });
67
+ expect(client.oauthProvider).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ describe('MCPClient.start() — OAuth dance', () => {
72
+ it('calls startCallbackServer and finishAuth when UnauthorizedError is thrown', async () => {
73
+ const client = new MCPClient('linear', 'linear', {
74
+ type: 'http',
75
+ url: 'https://mcp.linear.app/mcp',
76
+ auth: { type: 'oauth' },
77
+ });
78
+
79
+ const mockClose = vi.fn();
80
+ vi.spyOn(client.oauthProvider!, 'startCallbackServer').mockResolvedValue({
81
+ codePromise: Promise.resolve('auth-code-xyz'),
82
+ close: mockClose,
83
+ });
84
+
85
+ const { UnauthorizedError } = await import('@modelcontextprotocol/sdk/client/auth.js');
86
+ const originalTransport = client['transport'];
87
+ const transportsPassedToConnect: unknown[] = [];
88
+ vi.spyOn(client['client'], 'connect').mockImplementation(async (t) => {
89
+ transportsPassedToConnect.push(t);
90
+ if (transportsPassedToConnect.length === 1) throw new UnauthorizedError('needs auth');
91
+ });
92
+ const mockFinishAuth = vi.fn().mockResolvedValue(undefined);
93
+ (client['transport'] as any).finishAuth = mockFinishAuth;
94
+
95
+ await client.start();
96
+
97
+ expect(mockFinishAuth).toHaveBeenCalledWith('auth-code-xyz');
98
+ expect(transportsPassedToConnect).toHaveLength(2);
99
+ // Second connect must use a fresh transport (not the already-started one)
100
+ expect(transportsPassedToConnect[1]).not.toBe(originalTransport);
101
+ expect(mockClose).toHaveBeenCalled();
102
+
103
+ vi.restoreAllMocks();
104
+ });
105
+
106
+ it('closes the callback server and rethrows on non-OAuth error', async () => {
107
+ const client = new MCPClient('linear', 'linear', {
108
+ type: 'http',
109
+ url: 'https://mcp.linear.app/mcp',
110
+ auth: { type: 'oauth' },
111
+ });
112
+
113
+ const mockClose = vi.fn();
114
+ vi.spyOn(client.oauthProvider!, 'startCallbackServer').mockResolvedValue({
115
+ codePromise: Promise.resolve('unused'),
116
+ close: mockClose,
117
+ });
118
+
119
+ vi.spyOn(client['client'], 'connect').mockRejectedValue(new Error('network failure'));
120
+
121
+ await expect(client.start()).rejects.toThrow('network failure');
122
+ expect(mockClose).toHaveBeenCalled();
123
+
124
+ vi.restoreAllMocks();
125
+ });
126
+
127
+ it('closes the callback server immediately when connect() succeeds (tokens exist)', async () => {
128
+ const client = new MCPClient('linear', 'linear', {
129
+ type: 'http',
130
+ url: 'https://mcp.linear.app/mcp',
131
+ auth: { type: 'oauth' },
132
+ });
133
+
134
+ const mockClose = vi.fn();
135
+ vi.spyOn(client.oauthProvider!, 'startCallbackServer').mockResolvedValue({
136
+ codePromise: new Promise(() => { /* never resolves */ }),
137
+ close: mockClose,
138
+ });
139
+
140
+ vi.spyOn(client['client'], 'connect').mockResolvedValue(undefined);
141
+
142
+ await client.start();
143
+
144
+ expect(mockClose).toHaveBeenCalled();
145
+
146
+ vi.restoreAllMocks();
147
+ });
148
+ });
@@ -0,0 +1,128 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
+ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
5
+ import type { Tool } from '../tools/tool-registry.js';
6
+ import type { MCPServerDef } from './plugin-loader.js';
7
+ import { StudioOAuthProvider } from './oauth-provider.js';
8
+
9
+ export class MCPClient {
10
+ private client: Client;
11
+ private transport: StdioClientTransport | StreamableHTTPClientTransport;
12
+ oauthProvider: StudioOAuthProvider | undefined;
13
+
14
+ constructor(
15
+ private pluginName: string,
16
+ private serverName: string,
17
+ private def: MCPServerDef
18
+ ) {
19
+ if (def.type === 'http') {
20
+ if (def.auth?.type === 'oauth') {
21
+ this.oauthProvider = new StudioOAuthProvider(def.url);
22
+ this.transport = new StreamableHTTPClientTransport(new URL(def.url), {
23
+ authProvider: this.oauthProvider,
24
+ });
25
+ } else {
26
+ const headers = this.resolveEnv(def.headers ?? {});
27
+ this.transport = new StreamableHTTPClientTransport(new URL(def.url), {
28
+ requestInit: Object.keys(headers).length > 0 ? { headers } : undefined,
29
+ });
30
+ }
31
+ } else {
32
+ const env = this.resolveEnv(def.env ?? {});
33
+ this.transport = new StdioClientTransport({
34
+ command: def.command,
35
+ args: def.args ?? [],
36
+ env: { ...process.env, ...env } as Record<string, string>,
37
+ });
38
+ }
39
+ this.client = new Client(
40
+ { name: 'studio', version: '1.0.0' },
41
+ { capabilities: {} }
42
+ );
43
+ }
44
+
45
+ toolPrefix(): string {
46
+ return `${this.pluginName}-${this.serverName}`;
47
+ }
48
+
49
+ /** Resolves ${VAR_NAME} placeholders from process.env. */
50
+ resolveEnv(env: Record<string, string>): Record<string, string> {
51
+ const result: Record<string, string> = {};
52
+ for (const [key, val] of Object.entries(env)) {
53
+ result[key] = val.replace(/\$\{([^}]+)\}/g, (_, v: string) => {
54
+ if (process.env[v] === undefined) {
55
+ console.warn(`Warning: env var '${v}' not set (referenced in MCP server config for plugin '${this.pluginName}')`);
56
+ }
57
+ return process.env[v] ?? '';
58
+ });
59
+ }
60
+ return result;
61
+ }
62
+
63
+ async start(): Promise<void> {
64
+ if (this.oauthProvider) {
65
+ const { codePromise, close } = await this.oauthProvider.startCallbackServer();
66
+ try {
67
+ await this.client.connect(this.transport);
68
+ close();
69
+ } catch (err) {
70
+ if (err instanceof UnauthorizedError) {
71
+ const code = await codePromise;
72
+ await (this.transport as StreamableHTTPClientTransport).finishAuth(code);
73
+ close();
74
+ // The SDK closes the client after UnauthorizedError but transport.close() aborts
75
+ // _abortController without clearing it, so start() would throw "already started!".
76
+ // Recreate the transport; the client itself is already reset (_transport = undefined).
77
+ this.transport = new StreamableHTTPClientTransport(
78
+ new URL((this.def as { url: string }).url),
79
+ { authProvider: this.oauthProvider }
80
+ );
81
+ await this.client.connect(this.transport);
82
+ } else {
83
+ close();
84
+ throw err;
85
+ }
86
+ }
87
+ } else {
88
+ await this.client.connect(this.transport);
89
+ }
90
+ }
91
+
92
+ async getTools(): Promise<Tool[]> {
93
+ const { tools } = await this.client.listTools();
94
+ const prefix = this.toolPrefix();
95
+
96
+ return tools.map((t) => ({
97
+ name: `${prefix}-${t.name}`,
98
+ description: t.description ?? '',
99
+ parameters: (t.inputSchema as Record<string, unknown>) ?? { type: 'object', properties: {} },
100
+ execute: async (args: Record<string, unknown>) => {
101
+ try {
102
+ const result = await this.client.callTool({ name: t.name, arguments: args });
103
+ // Extract text content from MCP result (filter out images/resources for now)
104
+ const rawContent = result.content as Array<{ type: string; text?: string }>;
105
+ const text = (rawContent ?? [])
106
+ .filter((c): c is { type: 'text'; text: string } => c.type === 'text')
107
+ .map((c) => c.text)
108
+ .join('\n');
109
+ return { success: true, output: text || JSON.stringify(result.content) };
110
+ } catch (err) {
111
+ return {
112
+ success: false,
113
+ output: null,
114
+ error: (err as Error).message,
115
+ };
116
+ }
117
+ },
118
+ }));
119
+ }
120
+
121
+ async close(): Promise<void> {
122
+ try {
123
+ await this.client.close();
124
+ } catch {
125
+ // Ignore close errors — process may have already exited
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { StudioOAuthProvider } from './oauth-provider.js';
6
+
7
+ vi.mock('node:child_process', () => ({ exec: vi.fn() }));
8
+
9
+ let tmpDir: string;
10
+
11
+ beforeEach(async () => {
12
+ tmpDir = await mkdtemp(join(tmpdir(), 'studio-oauth-test-'));
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await rm(tmpDir, { recursive: true, force: true });
17
+ });
18
+
19
+ describe('StudioOAuthProvider — storage', () => {
20
+ it('tokens() returns undefined when no file exists', async () => {
21
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
22
+ expect(await p.tokens()).toBeUndefined();
23
+ });
24
+
25
+ it('saveTokens() + tokens() round-trips correctly', async () => {
26
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
27
+ const tokens = { access_token: 'tok123', token_type: 'bearer', expires_in: 3600, refresh_token: 'ref456' };
28
+ await p.saveTokens(tokens);
29
+ expect(await p.tokens()).toEqual(tokens);
30
+ });
31
+
32
+ it('clientInformation() returns undefined when no file exists', async () => {
33
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
34
+ expect(await p.clientInformation()).toBeUndefined();
35
+ });
36
+
37
+ it('saveClientInformation() + clientInformation() round-trips correctly', async () => {
38
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
39
+ const info = { client_id: 'cid', client_secret: 'csec' };
40
+ await p.saveClientInformation(info);
41
+ expect(await p.clientInformation()).toEqual(info);
42
+ });
43
+
44
+ it('two providers with same URL share the same storage file', async () => {
45
+ const p1 = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
46
+ const p2 = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
47
+ await p1.saveTokens({ access_token: 'shared', token_type: 'bearer' });
48
+ expect(await p2.tokens()).toEqual({ access_token: 'shared', token_type: 'bearer' });
49
+ });
50
+
51
+ it('two providers with different URLs use different storage files', async () => {
52
+ const p1 = new StudioOAuthProvider('https://mcp.linear.app/mcp', tmpDir);
53
+ const p2 = new StudioOAuthProvider('https://mcp.github.com/mcp', tmpDir);
54
+ await p1.saveTokens({ access_token: 'linear-tok', token_type: 'bearer' });
55
+ expect(await p2.tokens()).toBeUndefined();
56
+ });
57
+ });
58
+
59
+ describe('StudioOAuthProvider — PKCE', () => {
60
+ it('codeVerifier() throws before saveCodeVerifier() is called', () => {
61
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
62
+ expect(() => p.codeVerifier()).toThrow();
63
+ });
64
+
65
+ it('saveCodeVerifier() + codeVerifier() round-trips in memory', () => {
66
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
67
+ p.saveCodeVerifier('verifier-abc');
68
+ expect(p.codeVerifier()).toBe('verifier-abc');
69
+ });
70
+
71
+ it('codeVerifier is NOT persisted across instances', async () => {
72
+ const p1 = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
73
+ const p2 = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
74
+ p1.saveCodeVerifier('only-in-p1');
75
+ expect(() => p2.codeVerifier()).toThrow();
76
+ });
77
+ });
78
+
79
+ describe('StudioOAuthProvider — metadata', () => {
80
+ it('redirectUrl is undefined before startCallbackServer()', () => {
81
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
82
+ expect(p.redirectUrl).toBeUndefined();
83
+ });
84
+
85
+ it('clientMetadata includes redirect_uri after server starts', async () => {
86
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
87
+ const { close } = await p.startCallbackServer();
88
+ try {
89
+ const redirectUrl = p.redirectUrl;
90
+ expect(redirectUrl).toMatch(/^http:\/\/localhost:\d+\/callback$/);
91
+ const meta = p.clientMetadata;
92
+ expect(meta.client_name).toBe('Studio');
93
+ expect(meta.redirect_uris).toHaveLength(1);
94
+ expect(meta.redirect_uris[0].toString()).toMatch(/^http:\/\/localhost:\d+\/callback$/);
95
+ } finally {
96
+ close();
97
+ }
98
+ });
99
+ });
100
+
101
+ describe('StudioOAuthProvider — callback server', () => {
102
+ it('startCallbackServer() resolves codePromise when /callback?code=X is hit', async () => {
103
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
104
+ const { codePromise, close } = await p.startCallbackServer();
105
+
106
+ const port = (p.redirectUrl as string).match(/:(\d+)/)![1];
107
+ const res = await fetch(`http://localhost:${port}/callback?code=auth-code-123`);
108
+ expect(res.ok).toBe(true);
109
+ expect(await codePromise).toBe('auth-code-123');
110
+ close();
111
+ });
112
+
113
+ it('callback response contains success HTML', async () => {
114
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
115
+ const { close } = await p.startCallbackServer();
116
+
117
+ const port = (p.redirectUrl as string).match(/:(\d+)/)![1];
118
+ const res = await fetch(`http://localhost:${port}/callback?code=x`);
119
+ const html = await res.text();
120
+ expect(html).toContain('Authorization successful');
121
+ close();
122
+ });
123
+
124
+ it('codePromise rejects after timeout', async () => {
125
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir, 50);
126
+ const { codePromise, close } = await p.startCallbackServer();
127
+ await expect(codePromise).rejects.toThrow('timed out');
128
+ close();
129
+ });
130
+
131
+ it('sets callbackPort so redirectUrl reflects the server port', async () => {
132
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
133
+ expect(p.redirectUrl).toBeUndefined();
134
+ const { close } = await p.startCallbackServer();
135
+ expect(p.redirectUrl).toMatch(/^http:\/\/localhost:\d+\/callback$/);
136
+ close();
137
+ });
138
+
139
+ it('clears stale clientInformation but preserves tokens on startCallbackServer()', async () => {
140
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
141
+ // Seed stale state: clientInformation with old redirect_uri + existing tokens
142
+ await p.saveClientInformation({ client_id: 'old-client', redirect_uris: ['http://localhost:9999/callback'] } as any);
143
+ await p.saveTokens({ access_token: 'tok', token_type: 'Bearer' });
144
+
145
+ const { close } = await p.startCallbackServer();
146
+ close();
147
+
148
+ expect(await p.clientInformation()).toBeUndefined();
149
+ expect(await p.tokens()).toEqual({ access_token: 'tok', token_type: 'Bearer' });
150
+ });
151
+ });
152
+
153
+ describe('StudioOAuthProvider — redirectToAuthorization', () => {
154
+ it('prints the authorization URL to stderr', async () => {
155
+ const p = new StudioOAuthProvider('https://mcp.example.com/mcp', tmpDir);
156
+ const written: string[] = [];
157
+ const spy = vi.spyOn(process.stderr, 'write').mockImplementation((s) => {
158
+ written.push(String(s));
159
+ return true;
160
+ });
161
+
162
+ await p.redirectToAuthorization(new URL('https://auth.example.com/authorize?foo=bar'));
163
+
164
+ spy.mockRestore();
165
+ expect(written.join('')).toContain('https://auth.example.com/authorize');
166
+ });
167
+ });
@@ -0,0 +1,175 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { dirname, join } from 'node:path';
4
+ import { exec } from 'node:child_process';
5
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
6
+ import type { AddressInfo } from 'node:net';
7
+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
8
+ import type {
9
+ OAuthClientInformationMixed,
10
+ OAuthClientMetadata,
11
+ OAuthTokens,
12
+ } from '@modelcontextprotocol/sdk/shared/auth.js';
13
+
14
+ interface StorageData {
15
+ clientInformation?: OAuthClientInformationMixed;
16
+ tokens?: OAuthTokens;
17
+ }
18
+
19
+ function urlHash(url: string): string {
20
+ return createHash('sha256').update(url).digest('hex').slice(0, 16);
21
+ }
22
+
23
+ export class StudioOAuthProvider implements OAuthClientProvider {
24
+ private readonly filePath: string;
25
+ private _codeVerifier: string | undefined;
26
+ private _callbackPort: number | undefined;
27
+
28
+ constructor(
29
+ private readonly serverUrl: string,
30
+ private readonly storageDir: string = join(
31
+ process.env.HOME ?? process.env.USERPROFILE ?? '/tmp',
32
+ '.config', 'studio', 'oauth'
33
+ ),
34
+ private readonly timeoutMs: number = 5 * 60 * 1000
35
+ ) {
36
+ this.filePath = join(storageDir, `${urlHash(serverUrl)}.json`);
37
+ }
38
+
39
+ // --- Storage helpers ---
40
+
41
+ private async load(): Promise<StorageData> {
42
+ try {
43
+ const raw = await readFile(this.filePath, 'utf-8');
44
+ return JSON.parse(raw) as StorageData;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ private async save(data: StorageData): Promise<void> {
51
+ await mkdir(dirname(this.filePath), { recursive: true });
52
+ await writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf-8');
53
+ }
54
+
55
+ // --- OAuthClientProvider: tokens ---
56
+
57
+ async tokens(): Promise<OAuthTokens | undefined> {
58
+ return (await this.load()).tokens;
59
+ }
60
+
61
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
62
+ const data = await this.load();
63
+ await this.save({ ...data, tokens });
64
+ }
65
+
66
+ // --- OAuthClientProvider: client information ---
67
+
68
+ async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
69
+ return (await this.load()).clientInformation;
70
+ }
71
+
72
+ async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
73
+ const data = await this.load();
74
+ await this.save({ ...data, clientInformation: info });
75
+ }
76
+
77
+ // --- OAuthClientProvider: PKCE ---
78
+
79
+ saveCodeVerifier(codeVerifier: string): void {
80
+ this._codeVerifier = codeVerifier;
81
+ }
82
+
83
+ codeVerifier(): string {
84
+ if (this._codeVerifier === undefined) {
85
+ throw new Error('No code verifier saved for this session');
86
+ }
87
+ return this._codeVerifier;
88
+ }
89
+
90
+ // --- OAuthClientProvider: metadata ---
91
+
92
+ get redirectUrl(): string | undefined {
93
+ if (this._callbackPort === undefined) return undefined;
94
+ return `http://localhost:${this._callbackPort}/callback`;
95
+ }
96
+
97
+ get clientMetadata(): OAuthClientMetadata {
98
+ const redirectUri = this.redirectUrl ?? 'http://localhost/callback';
99
+ return {
100
+ client_name: 'Studio',
101
+ redirect_uris: [redirectUri],
102
+ };
103
+ }
104
+
105
+ redirectToAuthorization(authorizationUrl: URL): void {
106
+ const url = authorizationUrl.toString();
107
+ process.stderr.write(`\n Open this URL to authorize Studio:\n ${url}\n\n`);
108
+
109
+ const cmd =
110
+ process.platform === 'darwin' ? `open "${url}"` :
111
+ process.platform === 'win32' ? `start "" "${url}"` :
112
+ `xdg-open "${url}"`;
113
+
114
+ exec(cmd, () => { /* ignore errors — URL already printed */ });
115
+ }
116
+
117
+ // --- Callback server ---
118
+
119
+ async startCallbackServer(): Promise<{ codePromise: Promise<string>; close: () => void }> {
120
+ let resolveCode!: (code: string) => void;
121
+ let rejectCode!: (err: Error) => void;
122
+ const codePromise = new Promise<string>((res, rej) => {
123
+ resolveCode = res;
124
+ rejectCode = rej;
125
+ });
126
+
127
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
128
+ const url = new URL(req.url ?? '/', 'http://localhost');
129
+ if (url.pathname === '/callback') {
130
+ const code = url.searchParams.get('code');
131
+ if (code) {
132
+ res.writeHead(200, { 'Content-Type': 'text/html' });
133
+ res.end('<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>');
134
+ resolveCode(code);
135
+ } else {
136
+ res.writeHead(400);
137
+ res.end('Missing code parameter');
138
+ rejectCode(new Error('OAuth callback missing code parameter'));
139
+ }
140
+ } else {
141
+ res.writeHead(404);
142
+ res.end();
143
+ }
144
+ });
145
+
146
+ await new Promise<void>((res, rej) => {
147
+ server.listen(0, 'localhost', res);
148
+ server.on('error', rej);
149
+ });
150
+
151
+ const { port } = server.address() as AddressInfo;
152
+ this._callbackPort = port;
153
+
154
+ // Clear stale client registration — redirect_uri changes with each new port.
155
+ // SDK will re-register with the current port's redirect_uri. Tokens are preserved.
156
+ const existing = await this.load();
157
+ if (existing.clientInformation) {
158
+ await this.save({ tokens: existing.tokens });
159
+ }
160
+
161
+ const timeout = setTimeout(() => {
162
+ rejectCode(new Error('OAuth authorization timed out. Run again to retry.'));
163
+ server.close();
164
+ this._callbackPort = undefined;
165
+ }, this.timeoutMs);
166
+
167
+ const close = () => {
168
+ clearTimeout(timeout);
169
+ server.close();
170
+ this._callbackPort = undefined;
171
+ };
172
+
173
+ return { codePromise, close };
174
+ }
175
+ }