@orchid-labs/pluxx 0.1.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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +574 -0
  3. package/bin/pluxx.js +37 -0
  4. package/dist/cli/agent.d.ts +90 -0
  5. package/dist/cli/agent.d.ts.map +1 -0
  6. package/dist/cli/dev.d.ts +2 -0
  7. package/dist/cli/dev.d.ts.map +1 -0
  8. package/dist/cli/doctor.d.ts +19 -0
  9. package/dist/cli/doctor.d.ts.map +1 -0
  10. package/dist/cli/index.d.ts +24 -0
  11. package/dist/cli/index.d.ts.map +1 -0
  12. package/dist/cli/init-from-mcp.d.ts +145 -0
  13. package/dist/cli/init-from-mcp.d.ts.map +1 -0
  14. package/dist/cli/install.d.ts +56 -0
  15. package/dist/cli/install.d.ts.map +1 -0
  16. package/dist/cli/lint.d.ts +18 -0
  17. package/dist/cli/lint.d.ts.map +1 -0
  18. package/dist/cli/migrate.d.ts +2 -0
  19. package/dist/cli/migrate.d.ts.map +1 -0
  20. package/dist/cli/prompt.d.ts +20 -0
  21. package/dist/cli/prompt.d.ts.map +1 -0
  22. package/dist/cli/publish.d.ts +70 -0
  23. package/dist/cli/publish.d.ts.map +1 -0
  24. package/dist/cli/runtime.d.ts +20 -0
  25. package/dist/cli/runtime.d.ts.map +1 -0
  26. package/dist/cli/sync-from-mcp.d.ts +32 -0
  27. package/dist/cli/sync-from-mcp.d.ts.map +1 -0
  28. package/dist/cli/test.d.ts +33 -0
  29. package/dist/cli/test.d.ts.map +1 -0
  30. package/dist/compatibility/matrix.d.ts +14 -0
  31. package/dist/compatibility/matrix.d.ts.map +1 -0
  32. package/dist/config/define.d.ts +18 -0
  33. package/dist/config/define.d.ts.map +1 -0
  34. package/dist/config/load.d.ts +7 -0
  35. package/dist/config/load.d.ts.map +1 -0
  36. package/dist/generators/amp/index.d.ts +13 -0
  37. package/dist/generators/amp/index.d.ts.map +1 -0
  38. package/dist/generators/base.d.ts +49 -0
  39. package/dist/generators/base.d.ts.map +1 -0
  40. package/dist/generators/claude-code/index.d.ts +7 -0
  41. package/dist/generators/claude-code/index.d.ts.map +1 -0
  42. package/dist/generators/cline/index.d.ts +14 -0
  43. package/dist/generators/cline/index.d.ts.map +1 -0
  44. package/dist/generators/codex/index.d.ts +9 -0
  45. package/dist/generators/codex/index.d.ts.map +1 -0
  46. package/dist/generators/cursor/index.d.ts +11 -0
  47. package/dist/generators/cursor/index.d.ts.map +1 -0
  48. package/dist/generators/gemini-cli/index.d.ts +13 -0
  49. package/dist/generators/gemini-cli/index.d.ts.map +1 -0
  50. package/dist/generators/github-copilot/index.d.ts +11 -0
  51. package/dist/generators/github-copilot/index.d.ts.map +1 -0
  52. package/dist/generators/hooks-warning.d.ts +3 -0
  53. package/dist/generators/hooks-warning.d.ts.map +1 -0
  54. package/dist/generators/index.d.ts +11 -0
  55. package/dist/generators/index.d.ts.map +1 -0
  56. package/dist/generators/opencode/index.d.ts +15 -0
  57. package/dist/generators/opencode/index.d.ts.map +1 -0
  58. package/dist/generators/openhands/index.d.ts +11 -0
  59. package/dist/generators/openhands/index.d.ts.map +1 -0
  60. package/dist/generators/roo-code/index.d.ts +14 -0
  61. package/dist/generators/roo-code/index.d.ts.map +1 -0
  62. package/dist/generators/shared/claude-family.d.ts +18 -0
  63. package/dist/generators/shared/claude-family.d.ts.map +1 -0
  64. package/dist/generators/warp/index.d.ts +13 -0
  65. package/dist/generators/warp/index.d.ts.map +1 -0
  66. package/dist/hook-events.d.ts +4 -0
  67. package/dist/hook-events.d.ts.map +1 -0
  68. package/dist/index.d.ts +7 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +5302 -0
  71. package/dist/mcp/introspect.d.ts +34 -0
  72. package/dist/mcp/introspect.d.ts.map +1 -0
  73. package/dist/permissions.d.ts +18 -0
  74. package/dist/permissions.d.ts.map +1 -0
  75. package/dist/schema.d.ts +9457 -0
  76. package/dist/schema.d.ts.map +1 -0
  77. package/dist/user-config.d.ts +19 -0
  78. package/dist/user-config.d.ts.map +1 -0
  79. package/dist/validation/platform-rules.d.ts +64 -0
  80. package/dist/validation/platform-rules.d.ts.map +1 -0
  81. package/package.json +76 -0
  82. package/src/cli/agent.ts +1030 -0
  83. package/src/cli/dev.ts +112 -0
  84. package/src/cli/doctor.ts +588 -0
  85. package/src/cli/index.ts +2414 -0
  86. package/src/cli/init-from-mcp.ts +1611 -0
  87. package/src/cli/install.ts +698 -0
  88. package/src/cli/lint.ts +1219 -0
  89. package/src/cli/migrate.ts +614 -0
  90. package/src/cli/prompt.ts +82 -0
  91. package/src/cli/publish.ts +401 -0
  92. package/src/cli/runtime.ts +86 -0
  93. package/src/cli/sync-from-mcp.ts +563 -0
  94. package/src/cli/test.ts +134 -0
  95. package/src/compatibility/matrix.ts +149 -0
  96. package/src/config/define.ts +20 -0
  97. package/src/config/load.ts +74 -0
  98. package/src/generators/amp/index.ts +63 -0
  99. package/src/generators/base.ts +188 -0
  100. package/src/generators/claude-code/index.ts +29 -0
  101. package/src/generators/cline/index.ts +35 -0
  102. package/src/generators/codex/index.ts +120 -0
  103. package/src/generators/cursor/index.ts +158 -0
  104. package/src/generators/gemini-cli/index.ts +83 -0
  105. package/src/generators/github-copilot/index.ts +32 -0
  106. package/src/generators/hooks-warning.ts +51 -0
  107. package/src/generators/index.ts +71 -0
  108. package/src/generators/opencode/index.ts +526 -0
  109. package/src/generators/openhands/index.ts +32 -0
  110. package/src/generators/roo-code/index.ts +35 -0
  111. package/src/generators/shared/claude-family.ts +215 -0
  112. package/src/generators/warp/index.ts +32 -0
  113. package/src/hook-events.ts +33 -0
  114. package/src/index.ts +23 -0
  115. package/src/mcp/introspect.ts +834 -0
  116. package/src/permissions.ts +258 -0
  117. package/src/schema.ts +312 -0
  118. package/src/user-config.ts +177 -0
  119. package/src/validation/platform-rules.ts +565 -0
@@ -0,0 +1,834 @@
1
+ import { spawn } from 'child_process'
2
+ import * as readline from 'readline'
3
+ import type { McpAuth, McpServer } from '../schema'
4
+
5
+ const MCP_PROTOCOL_VERSION = '2025-03-26'
6
+ const CLIENT_INFO = {
7
+ name: 'pluxx',
8
+ version: '0.1.0',
9
+ }
10
+ const DEFAULT_TIMEOUT_MS = 10_000
11
+
12
+ export interface IntrospectedMcpTool {
13
+ name: string
14
+ title?: string
15
+ description?: string
16
+ inputSchema?: Record<string, unknown>
17
+ }
18
+
19
+ export interface IntrospectedMcpServer {
20
+ protocolVersion: string
21
+ instructions?: string
22
+ serverInfo: {
23
+ name: string
24
+ title?: string
25
+ version?: string
26
+ description?: string
27
+ websiteUrl?: string
28
+ }
29
+ tools: IntrospectedMcpTool[]
30
+ }
31
+
32
+ export class McpIntrospectionError extends Error {
33
+ constructor(
34
+ message: string,
35
+ readonly status?: number,
36
+ readonly context?: {
37
+ responseHeaders?: Record<string, string>
38
+ responseBodySnippet?: string
39
+ responseUrl?: string
40
+ },
41
+ ) {
42
+ super(message)
43
+ this.name = 'McpIntrospectionError'
44
+ }
45
+ }
46
+
47
+ interface JsonRpcSuccess<T> {
48
+ jsonrpc: '2.0'
49
+ id: number | string
50
+ result: T
51
+ }
52
+
53
+ interface JsonRpcFailure {
54
+ jsonrpc: '2.0'
55
+ id: number | string | null
56
+ error: {
57
+ code: number
58
+ message: string
59
+ data?: unknown
60
+ }
61
+ }
62
+
63
+ type JsonRpcEnvelope<T> = JsonRpcSuccess<T> | JsonRpcFailure
64
+
65
+ interface InitializeResult {
66
+ protocolVersion?: string
67
+ instructions?: string
68
+ serverInfo?: {
69
+ name?: string
70
+ title?: string
71
+ version?: string
72
+ description?: string
73
+ websiteUrl?: string
74
+ }
75
+ }
76
+
77
+ interface ListToolsResult {
78
+ tools?: Array<{
79
+ name: string
80
+ title?: string
81
+ description?: string
82
+ inputSchema?: Record<string, unknown>
83
+ }>
84
+ nextCursor?: string
85
+ }
86
+
87
+ interface McpClient {
88
+ request<T>(method: string, params?: Record<string, unknown>): Promise<T>
89
+ notify(method: string, params?: Record<string, unknown>): Promise<void>
90
+ close(): Promise<void>
91
+ }
92
+
93
+ export async function introspectMcpServer(server: McpServer): Promise<IntrospectedMcpServer> {
94
+ if (server.transport === 'stdio') {
95
+ const client = await createStdioClient(server)
96
+ return await introspectWithClient(client)
97
+ }
98
+
99
+ if (server.transport === 'sse') {
100
+ const client = await createSseClient(server)
101
+ return await introspectWithClient(client)
102
+ }
103
+
104
+ try {
105
+ return await introspectWithClient(createHttpClient(server))
106
+ } catch (error) {
107
+ if (
108
+ error instanceof McpIntrospectionError
109
+ && (error.status === 400 || error.status === 404 || error.status === 405)
110
+ ) {
111
+ const sseClient = await createSseClient({
112
+ ...server,
113
+ transport: 'sse',
114
+ })
115
+ return await introspectWithClient(sseClient)
116
+ }
117
+
118
+ throw error
119
+ }
120
+ }
121
+
122
+ async function introspectWithClient(client: McpClient): Promise<IntrospectedMcpServer> {
123
+ try {
124
+ const initialize = await client.request<InitializeResult>('initialize', {
125
+ protocolVersion: MCP_PROTOCOL_VERSION,
126
+ capabilities: {},
127
+ clientInfo: CLIENT_INFO,
128
+ })
129
+
130
+ await client.notify('notifications/initialized')
131
+
132
+ const tools = await listAllTools(client)
133
+ if (tools.length === 0) {
134
+ throw new McpIntrospectionError(
135
+ 'The MCP server initialized successfully but exposed no tools. pluxx init --from-mcp currently scaffolds from tool metadata only.',
136
+ )
137
+ }
138
+
139
+ return {
140
+ protocolVersion: initialize.protocolVersion ?? MCP_PROTOCOL_VERSION,
141
+ instructions: initialize.instructions,
142
+ serverInfo: {
143
+ name: initialize.serverInfo?.name ?? 'mcp-server',
144
+ title: initialize.serverInfo?.title,
145
+ version: initialize.serverInfo?.version,
146
+ description: initialize.serverInfo?.description,
147
+ websiteUrl: initialize.serverInfo?.websiteUrl,
148
+ },
149
+ tools,
150
+ }
151
+ } finally {
152
+ await client.close()
153
+ }
154
+ }
155
+
156
+ async function listAllTools(client: McpClient): Promise<IntrospectedMcpTool[]> {
157
+ const tools: IntrospectedMcpTool[] = []
158
+ let cursor: string | undefined
159
+
160
+ while (true) {
161
+ const result = await client.request<ListToolsResult>('tools/list', cursor ? { cursor } : undefined)
162
+ tools.push(...(result.tools ?? []))
163
+ cursor = result.nextCursor
164
+ if (!cursor) {
165
+ return tools
166
+ }
167
+ }
168
+ }
169
+
170
+ function createHttpClient(server: Exclude<McpServer, { transport: 'stdio' }>): McpClient {
171
+ let sessionId: string | null = null
172
+
173
+ return {
174
+ async request<T>(method: string, params?: Record<string, unknown>): Promise<T> {
175
+ const id = nextRequestId()
176
+ const response = await fetch(server.url, {
177
+ method: 'POST',
178
+ headers: buildHttpHeaders(server.auth, sessionId),
179
+ body: JSON.stringify({
180
+ jsonrpc: '2.0',
181
+ id,
182
+ method,
183
+ ...(params ? { params } : {}),
184
+ }),
185
+ })
186
+
187
+ await throwIfLikelyAuthRedirect(response, 'MCP HTTP request was redirected to an authentication page.')
188
+
189
+ if (!response.ok) {
190
+ const context = await extractHttpErrorContext(response)
191
+ throw new McpIntrospectionError(
192
+ `MCP HTTP request failed with ${response.status} ${response.statusText}.`,
193
+ response.status,
194
+ context,
195
+ )
196
+ }
197
+
198
+ sessionId = response.headers.get('Mcp-Session-Id') ?? sessionId
199
+ const envelope = await parseHttpEnvelope<T>(response, id)
200
+ return unwrapEnvelope(envelope)
201
+ },
202
+
203
+ async notify(method: string, params?: Record<string, unknown>): Promise<void> {
204
+ const response = await fetch(server.url, {
205
+ method: 'POST',
206
+ headers: buildHttpHeaders(server.auth, sessionId),
207
+ body: JSON.stringify({
208
+ jsonrpc: '2.0',
209
+ method,
210
+ ...(params ? { params } : {}),
211
+ }),
212
+ })
213
+
214
+ await throwIfLikelyAuthRedirect(response, 'MCP HTTP notification was redirected to an authentication page.')
215
+
216
+ if (!response.ok && response.status !== 202) {
217
+ const context = await extractHttpErrorContext(response)
218
+ throw new McpIntrospectionError(
219
+ `MCP HTTP notification failed with ${response.status} ${response.statusText}.`,
220
+ response.status,
221
+ context,
222
+ )
223
+ }
224
+ },
225
+
226
+ async close(): Promise<void> {
227
+ if (!sessionId) return
228
+ try {
229
+ await fetch(server.url, {
230
+ method: 'DELETE',
231
+ headers: {
232
+ 'Mcp-Session-Id': sessionId,
233
+ 'Mcp-Protocol-Version': MCP_PROTOCOL_VERSION,
234
+ },
235
+ })
236
+ } catch {
237
+ // Session cleanup is best effort only.
238
+ }
239
+ },
240
+ }
241
+ }
242
+
243
+ async function createSseClient(server: Extract<McpServer, { transport: 'sse' }>): Promise<McpClient> {
244
+ let sessionId: string | null = null
245
+ let endpointUrl: string | null = null
246
+ let isClosed = false
247
+ const pending = new Map<number, {
248
+ resolve: (value: unknown) => void
249
+ reject: (error: Error) => void
250
+ timeout: ReturnType<typeof setTimeout>
251
+ }>()
252
+ const abortController = new AbortController()
253
+ let resolveEndpoint!: (value: string) => void
254
+ let rejectEndpoint!: (error: Error) => void
255
+ let endpointSettled = false
256
+
257
+ const endpointReady = new Promise<string>((resolve, reject) => {
258
+ resolveEndpoint = (value) => {
259
+ endpointSettled = true
260
+ resolve(value)
261
+ }
262
+ rejectEndpoint = (error) => {
263
+ endpointSettled = true
264
+ reject(error)
265
+ }
266
+ })
267
+
268
+ const streamPromise = (async () => {
269
+ const response = await fetch(server.url, {
270
+ method: 'GET',
271
+ headers: buildSseStreamHeaders(server.auth, sessionId),
272
+ signal: abortController.signal,
273
+ })
274
+
275
+ await throwIfLikelyAuthRedirect(response, 'MCP SSE stream was redirected to an authentication page.')
276
+
277
+ if (!response.ok) {
278
+ const context = await extractHttpErrorContext(response)
279
+ throw new McpIntrospectionError(
280
+ `MCP SSE stream failed with ${response.status} ${response.statusText}.`,
281
+ response.status,
282
+ context,
283
+ )
284
+ }
285
+
286
+ const contentType = response.headers.get('content-type') ?? ''
287
+ if (!contentType.includes('text/event-stream')) {
288
+ throw new McpIntrospectionError(`Unsupported MCP SSE response content type: ${contentType || 'unknown'}.`)
289
+ }
290
+
291
+ sessionId = response.headers.get('Mcp-Session-Id') ?? sessionId
292
+
293
+ if (!response.body) {
294
+ throw new McpIntrospectionError('MCP SSE stream opened without a readable response body.')
295
+ }
296
+
297
+ const reader = response.body.getReader()
298
+ const decoder = new TextDecoder()
299
+ let buffer = ''
300
+ let eventName = 'message'
301
+ let dataLines: string[] = []
302
+
303
+ const flushEvent = () => {
304
+ if (dataLines.length === 0) {
305
+ eventName = 'message'
306
+ return
307
+ }
308
+
309
+ const data = dataLines.join('\n').trim()
310
+ dataLines = []
311
+ const currentEvent = eventName || 'message'
312
+ eventName = 'message'
313
+
314
+ if (!data) {
315
+ return
316
+ }
317
+
318
+ if (currentEvent === 'endpoint') {
319
+ endpointUrl = new URL(data, server.url).toString()
320
+ if (!endpointSettled) {
321
+ resolveEndpoint(endpointUrl)
322
+ }
323
+ return
324
+ }
325
+
326
+ if (currentEvent !== 'message') {
327
+ return
328
+ }
329
+
330
+ const envelope = JSON.parse(data) as JsonRpcEnvelope<unknown>
331
+ if (typeof envelope !== 'object' || envelope === null || !('id' in envelope)) {
332
+ return
333
+ }
334
+
335
+ const requestId = typeof envelope.id === 'number' ? envelope.id : Number.NaN
336
+ if (!Number.isFinite(requestId)) return
337
+
338
+ const entry = pending.get(requestId)
339
+ if (!entry) return
340
+
341
+ pending.delete(requestId)
342
+ clearTimeout(entry.timeout)
343
+
344
+ try {
345
+ entry.resolve(unwrapEnvelope(envelope))
346
+ } catch (error) {
347
+ entry.reject(error instanceof Error ? error : new Error(String(error)))
348
+ }
349
+ }
350
+
351
+ while (true) {
352
+ const { value, done } = await reader.read()
353
+ if (done) break
354
+
355
+ buffer += decoder.decode(value, { stream: true })
356
+ const lines = buffer.split(/\r?\n/)
357
+ buffer = lines.pop() ?? ''
358
+
359
+ for (const line of lines) {
360
+ if (line === '') {
361
+ flushEvent()
362
+ continue
363
+ }
364
+
365
+ if (line.startsWith(':')) {
366
+ continue
367
+ }
368
+
369
+ const separatorIndex = line.indexOf(':')
370
+ const field = separatorIndex === -1 ? line : line.slice(0, separatorIndex)
371
+ const rawValue = separatorIndex === -1 ? '' : line.slice(separatorIndex + 1).trimStart()
372
+
373
+ if (field === 'event') {
374
+ eventName = rawValue || 'message'
375
+ } else if (field === 'data') {
376
+ dataLines.push(rawValue)
377
+ }
378
+ }
379
+ }
380
+
381
+ if (buffer) {
382
+ const tailLines = buffer.split(/\r?\n/)
383
+ for (const line of tailLines) {
384
+ if (line.startsWith('data:')) {
385
+ dataLines.push(line.slice(5).trimStart())
386
+ }
387
+ }
388
+ }
389
+ flushEvent()
390
+
391
+ if (!endpointSettled) {
392
+ rejectEndpoint(new McpIntrospectionError('MCP SSE stream did not provide the required endpoint event.'))
393
+ return
394
+ }
395
+
396
+ if (!isClosed && pending.size > 0) {
397
+ const error = new McpIntrospectionError('MCP SSE stream ended before pluxx finished introspecting it.')
398
+ for (const entry of pending.values()) {
399
+ clearTimeout(entry.timeout)
400
+ entry.reject(error)
401
+ }
402
+ pending.clear()
403
+ }
404
+ })().catch((error) => {
405
+ if (isClosed && error instanceof DOMException && error.name === 'AbortError') {
406
+ return
407
+ }
408
+
409
+ const wrapped = error instanceof Error
410
+ ? error
411
+ : new McpIntrospectionError(String(error))
412
+
413
+ if (!endpointSettled) {
414
+ rejectEndpoint(wrapped)
415
+ }
416
+
417
+ for (const entry of pending.values()) {
418
+ clearTimeout(entry.timeout)
419
+ entry.reject(wrapped)
420
+ }
421
+ pending.clear()
422
+ })
423
+
424
+ await endpointReady
425
+
426
+ return {
427
+ async request<T>(method: string, params?: Record<string, unknown>): Promise<T> {
428
+ const requestId = nextRequestId()
429
+ const endpoint = endpointUrl ?? await endpointReady
430
+
431
+ const resultPromise = new Promise<T>((resolve, reject) => {
432
+ const timeout = setTimeout(() => {
433
+ pending.delete(requestId)
434
+ reject(new McpIntrospectionError(`Timed out waiting for MCP SSE response to ${method}.`))
435
+ }, DEFAULT_TIMEOUT_MS)
436
+
437
+ pending.set(requestId, {
438
+ resolve: (value) => {
439
+ clearTimeout(timeout)
440
+ resolve(value as T)
441
+ },
442
+ reject: (error) => {
443
+ clearTimeout(timeout)
444
+ reject(error)
445
+ },
446
+ timeout,
447
+ })
448
+ })
449
+
450
+ let response: Response
451
+ try {
452
+ response = await fetch(endpoint, {
453
+ method: 'POST',
454
+ headers: buildHttpHeaders(server.auth, sessionId),
455
+ body: JSON.stringify({
456
+ jsonrpc: '2.0',
457
+ id: requestId,
458
+ method,
459
+ ...(params ? { params } : {}),
460
+ }),
461
+ })
462
+ } catch (error) {
463
+ const entry = pending.get(requestId)
464
+ if (entry) {
465
+ pending.delete(requestId)
466
+ clearTimeout(entry.timeout)
467
+ }
468
+ throw new McpIntrospectionError(`MCP SSE request failed: ${error instanceof Error ? error.message : String(error)}`)
469
+ }
470
+
471
+ await throwIfLikelyAuthRedirect(response, 'MCP SSE request was redirected to an authentication page.')
472
+
473
+ if (!response.ok && response.status !== 202) {
474
+ const entry = pending.get(requestId)
475
+ if (entry) {
476
+ pending.delete(requestId)
477
+ clearTimeout(entry.timeout)
478
+ }
479
+ const context = await extractHttpErrorContext(response)
480
+ throw new McpIntrospectionError(
481
+ `MCP SSE request failed with ${response.status} ${response.statusText}.`,
482
+ response.status,
483
+ context,
484
+ )
485
+ }
486
+
487
+ sessionId = response.headers.get('Mcp-Session-Id') ?? sessionId
488
+ const contentType = response.headers.get('content-type') ?? ''
489
+ if (contentType.includes('application/json') || contentType.includes('text/event-stream')) {
490
+ const entry = pending.get(requestId)
491
+ if (entry) {
492
+ pending.delete(requestId)
493
+ clearTimeout(entry.timeout)
494
+ }
495
+ const envelope = await parseHttpEnvelope<T>(response, requestId)
496
+ return unwrapEnvelope(envelope)
497
+ }
498
+
499
+ return await resultPromise
500
+ },
501
+
502
+ async notify(method: string, params?: Record<string, unknown>): Promise<void> {
503
+ const endpoint = endpointUrl ?? await endpointReady
504
+ const response = await fetch(endpoint, {
505
+ method: 'POST',
506
+ headers: buildHttpHeaders(server.auth, sessionId),
507
+ body: JSON.stringify({
508
+ jsonrpc: '2.0',
509
+ method,
510
+ ...(params ? { params } : {}),
511
+ }),
512
+ })
513
+
514
+ await throwIfLikelyAuthRedirect(response, 'MCP SSE notification was redirected to an authentication page.')
515
+
516
+ if (!response.ok && response.status !== 202) {
517
+ const context = await extractHttpErrorContext(response)
518
+ throw new McpIntrospectionError(
519
+ `MCP SSE notification failed with ${response.status} ${response.statusText}.`,
520
+ response.status,
521
+ context,
522
+ )
523
+ }
524
+
525
+ sessionId = response.headers.get('Mcp-Session-Id') ?? sessionId
526
+ },
527
+
528
+ async close(): Promise<void> {
529
+ isClosed = true
530
+ abortController.abort()
531
+ await streamPromise
532
+ },
533
+ }
534
+ }
535
+
536
+ async function createStdioClient(server: Extract<McpServer, { transport: 'stdio' }>): Promise<McpClient> {
537
+ const child = spawn(server.command, server.args ?? [], {
538
+ env: {
539
+ ...process.env,
540
+ ...(server.env ?? {}),
541
+ },
542
+ stdio: ['pipe', 'pipe', 'pipe'],
543
+ })
544
+
545
+ const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
546
+ const stdout = readline.createInterface({
547
+ input: child.stdout,
548
+ crlfDelay: Infinity,
549
+ })
550
+
551
+ stdout.on('line', (line) => {
552
+ if (!line.trim()) return
553
+
554
+ let envelope: JsonRpcEnvelope<unknown>
555
+ try {
556
+ envelope = JSON.parse(line) as JsonRpcEnvelope<unknown>
557
+ } catch {
558
+ return
559
+ }
560
+
561
+ if (typeof envelope !== 'object' || envelope === null || !('id' in envelope)) {
562
+ return
563
+ }
564
+
565
+ const requestId = typeof envelope.id === 'number' ? envelope.id : Number.NaN
566
+ if (!Number.isFinite(requestId)) return
567
+
568
+ const entry = pending.get(requestId)
569
+ if (!entry) return
570
+
571
+ pending.delete(requestId)
572
+
573
+ try {
574
+ entry.resolve(unwrapEnvelope(envelope))
575
+ } catch (error) {
576
+ entry.reject(error instanceof Error ? error : new Error(String(error)))
577
+ }
578
+ })
579
+
580
+ child.once('exit', (code, signal) => {
581
+ const error = new McpIntrospectionError(
582
+ `MCP stdio process exited before pluxx finished introspecting it (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`,
583
+ )
584
+ for (const entry of pending.values()) {
585
+ entry.reject(error)
586
+ }
587
+ pending.clear()
588
+ })
589
+
590
+ child.once('error', (error) => {
591
+ const wrapped = new McpIntrospectionError(`Failed to start MCP stdio process: ${error.message}`)
592
+ for (const entry of pending.values()) {
593
+ entry.reject(wrapped)
594
+ }
595
+ pending.clear()
596
+ })
597
+
598
+ function send(message: Record<string, unknown>) {
599
+ child.stdin.write(JSON.stringify(message) + '\n')
600
+ }
601
+
602
+ return {
603
+ request<T>(method: string, params?: Record<string, unknown>): Promise<T> {
604
+ const id = nextRequestId()
605
+ send({
606
+ jsonrpc: '2.0',
607
+ id,
608
+ method,
609
+ ...(params ? { params } : {}),
610
+ })
611
+
612
+ return new Promise<T>((resolve, reject) => {
613
+ const timeout = setTimeout(() => {
614
+ pending.delete(id)
615
+ reject(new McpIntrospectionError(`Timed out waiting for MCP stdio response to ${method}.`))
616
+ }, DEFAULT_TIMEOUT_MS)
617
+
618
+ pending.set(id, {
619
+ resolve: (value) => {
620
+ clearTimeout(timeout)
621
+ resolve(value as T)
622
+ },
623
+ reject: (error) => {
624
+ clearTimeout(timeout)
625
+ reject(error)
626
+ },
627
+ })
628
+ })
629
+ },
630
+
631
+ async notify(method: string, params?: Record<string, unknown>): Promise<void> {
632
+ send({
633
+ jsonrpc: '2.0',
634
+ method,
635
+ ...(params ? { params } : {}),
636
+ })
637
+ },
638
+
639
+ async close(): Promise<void> {
640
+ stdout.close()
641
+ child.kill()
642
+ },
643
+ }
644
+ }
645
+
646
+ function buildHttpHeaders(auth: McpAuth | undefined, sessionId: string | null): HeadersInit {
647
+ const headers: Record<string, string> = {
648
+ 'Content-Type': 'application/json',
649
+ Accept: 'application/json, text/event-stream',
650
+ 'Mcp-Protocol-Version': MCP_PROTOCOL_VERSION,
651
+ }
652
+
653
+ if (sessionId) {
654
+ headers['Mcp-Session-Id'] = sessionId
655
+ }
656
+
657
+ const authHeader = resolveAuthHeader(auth)
658
+ if (authHeader) {
659
+ headers[authHeader.name] = authHeader.value
660
+ }
661
+
662
+ return headers
663
+ }
664
+
665
+ function buildSseStreamHeaders(auth: McpAuth | undefined, sessionId: string | null): HeadersInit {
666
+ const headers: Record<string, string> = {
667
+ Accept: 'text/event-stream',
668
+ 'Mcp-Protocol-Version': MCP_PROTOCOL_VERSION,
669
+ }
670
+
671
+ if (sessionId) {
672
+ headers['Mcp-Session-Id'] = sessionId
673
+ }
674
+
675
+ const authHeader = resolveAuthHeader(auth)
676
+ if (authHeader) {
677
+ headers[authHeader.name] = authHeader.value
678
+ }
679
+
680
+ return headers
681
+ }
682
+
683
+ function resolveAuthHeader(auth: McpAuth | undefined): { name: string; value: string } | null {
684
+ if (!auth || auth.type === 'none') return null
685
+ if (auth.type === 'platform') return null
686
+
687
+ const envValue = process.env[auth.envVar]
688
+ if (!envValue) {
689
+ throw new McpIntrospectionError(
690
+ `Missing environment variable ${auth.envVar} required to introspect the MCP server.`,
691
+ )
692
+ }
693
+
694
+ const headerName = auth.type === 'bearer'
695
+ ? auth.headerName ?? 'Authorization'
696
+ : auth.headerName
697
+ const headerTemplate = auth.headerTemplate ?? 'Bearer ${value}'
698
+
699
+ return {
700
+ name: headerName,
701
+ value: headerTemplate.replace('${value}', envValue),
702
+ }
703
+ }
704
+
705
+ async function extractHttpErrorContext(response: Response): Promise<{
706
+ responseHeaders?: Record<string, string>
707
+ responseBodySnippet?: string
708
+ responseUrl?: string
709
+ }> {
710
+ const responseHeaders: Record<string, string> = {}
711
+ for (const headerName of ['www-authenticate', 'location', 'content-type']) {
712
+ const value = response.headers.get(headerName)
713
+ if (value) {
714
+ responseHeaders[headerName] = value
715
+ }
716
+ }
717
+
718
+ let responseBodySnippet: string | undefined
719
+ try {
720
+ const contentType = response.headers.get('content-type') ?? ''
721
+ if (contentType.includes('application/json') || contentType.includes('text/plain') || contentType.includes('text/html')) {
722
+ const body = (await response.text()).trim()
723
+ if (body) {
724
+ responseBodySnippet = body.slice(0, 500)
725
+ }
726
+ }
727
+ } catch {
728
+ // Body extraction is best effort only.
729
+ }
730
+
731
+ const responseUrl = response.redirected && response.url ? response.url : undefined
732
+
733
+ if (Object.keys(responseHeaders).length === 0 && !responseBodySnippet && !responseUrl) {
734
+ return {}
735
+ }
736
+
737
+ return {
738
+ ...(Object.keys(responseHeaders).length > 0 ? { responseHeaders } : {}),
739
+ ...(responseBodySnippet ? { responseBodySnippet } : {}),
740
+ ...(responseUrl ? { responseUrl } : {}),
741
+ }
742
+ }
743
+
744
+ function isLikelyAuthRedirectResponse(response: Response): boolean {
745
+ if (!response.redirected || !response.url) {
746
+ return false
747
+ }
748
+
749
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
750
+ const finalUrl = response.url.toLowerCase()
751
+
752
+ return (contentType.includes('text/html') || contentType.includes('text/plain'))
753
+ && (
754
+ finalUrl.includes('oauth')
755
+ || finalUrl.includes('authorize')
756
+ || finalUrl.includes('login')
757
+ || finalUrl.includes('signin')
758
+ )
759
+ }
760
+
761
+ async function throwIfLikelyAuthRedirect(response: Response, message: string): Promise<void> {
762
+ if (!isLikelyAuthRedirectResponse(response)) {
763
+ return
764
+ }
765
+
766
+ const context = await extractHttpErrorContext(response)
767
+ throw new McpIntrospectionError(message, 401, context)
768
+ }
769
+
770
+ async function parseHttpEnvelope<T>(
771
+ response: Response,
772
+ requestId: number,
773
+ ): Promise<JsonRpcEnvelope<T>> {
774
+ const contentType = response.headers.get('content-type') ?? ''
775
+
776
+ if (contentType.includes('application/json')) {
777
+ return await response.json() as JsonRpcEnvelope<T>
778
+ }
779
+
780
+ if (contentType.includes('text/event-stream')) {
781
+ const payload = await response.text()
782
+ const envelopes = parseSsePayload(payload)
783
+ const match = envelopes.find((message) => message.id === requestId) as JsonRpcEnvelope<T> | undefined
784
+ if (!match) {
785
+ throw new McpIntrospectionError(
786
+ `MCP server returned an SSE stream for request ${requestId} without a matching JSON-RPC response.`,
787
+ )
788
+ }
789
+ return match
790
+ }
791
+
792
+ throw new McpIntrospectionError(`Unsupported MCP HTTP response content type: ${contentType || 'unknown'}.`)
793
+ }
794
+
795
+ function parseSsePayload(payload: string): Array<JsonRpcEnvelope<unknown>> {
796
+ const messages: Array<JsonRpcEnvelope<unknown>> = []
797
+ let dataLines: string[] = []
798
+
799
+ const flush = () => {
800
+ if (dataLines.length === 0) return
801
+ const data = dataLines.join('\n').trim()
802
+ dataLines = []
803
+ if (!data) return
804
+ messages.push(JSON.parse(data) as JsonRpcEnvelope<unknown>)
805
+ }
806
+
807
+ for (const line of payload.split(/\r?\n/)) {
808
+ if (line === '') {
809
+ flush()
810
+ continue
811
+ }
812
+
813
+ if (line.startsWith('data:')) {
814
+ dataLines.push(line.slice(5).trimStart())
815
+ }
816
+ }
817
+
818
+ flush()
819
+ return messages
820
+ }
821
+
822
+ function unwrapEnvelope<T>(envelope: JsonRpcEnvelope<T>): T {
823
+ if ('error' in envelope) {
824
+ throw new McpIntrospectionError(`MCP request failed: ${envelope.error.message}`)
825
+ }
826
+
827
+ return envelope.result
828
+ }
829
+
830
+ let requestCounter = 0
831
+ function nextRequestId(): number {
832
+ requestCounter += 1
833
+ return requestCounter
834
+ }