@orchid-labs/pluxx 0.1.1 → 0.1.3

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 (103) hide show
  1. package/README.md +25 -8
  2. package/bin/pluxx.js +19 -28
  3. package/dist/agents.d.ts +16 -0
  4. package/dist/agents.d.ts.map +1 -0
  5. package/dist/cli/agent.d.ts +62 -0
  6. package/dist/cli/agent.d.ts.map +1 -1
  7. package/dist/cli/doctor.d.ts +2 -0
  8. package/dist/cli/doctor.d.ts.map +1 -1
  9. package/dist/cli/entry.d.ts +2 -0
  10. package/dist/cli/entry.d.ts.map +1 -0
  11. package/dist/cli/index.d.ts +7 -1
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +21810 -0
  14. package/dist/cli/init-from-mcp.d.ts +17 -1
  15. package/dist/cli/init-from-mcp.d.ts.map +1 -1
  16. package/dist/cli/install.d.ts +1 -0
  17. package/dist/cli/install.d.ts.map +1 -1
  18. package/dist/cli/lint.d.ts +3 -1
  19. package/dist/cli/lint.d.ts.map +1 -1
  20. package/dist/cli/mcp-proxy.d.ts.map +1 -1
  21. package/dist/cli/migrate.d.ts.map +1 -1
  22. package/dist/cli/primitive-summary.d.ts +14 -0
  23. package/dist/cli/primitive-summary.d.ts.map +1 -0
  24. package/dist/cli/prompt.d.ts +1 -1
  25. package/dist/cli/publish.d.ts +6 -1
  26. package/dist/cli/publish.d.ts.map +1 -1
  27. package/dist/cli/sync-from-mcp.d.ts.map +1 -1
  28. package/dist/cli/verify-install.d.ts +25 -0
  29. package/dist/cli/verify-install.d.ts.map +1 -0
  30. package/dist/commands.d.ts +10 -0
  31. package/dist/commands.d.ts.map +1 -0
  32. package/dist/compiler-intent.d.ts +165 -0
  33. package/dist/compiler-intent.d.ts.map +1 -0
  34. package/dist/config/load.d.ts.map +1 -1
  35. package/dist/delegation.d.ts +11 -0
  36. package/dist/delegation.d.ts.map +1 -0
  37. package/dist/generators/amp/index.d.ts.map +1 -1
  38. package/dist/generators/base.d.ts +5 -0
  39. package/dist/generators/base.d.ts.map +1 -1
  40. package/dist/generators/claude-code/index.d.ts.map +1 -1
  41. package/dist/generators/cline/index.d.ts.map +1 -1
  42. package/dist/generators/codex/index.d.ts +4 -0
  43. package/dist/generators/codex/index.d.ts.map +1 -1
  44. package/dist/generators/cursor/index.d.ts +1 -0
  45. package/dist/generators/cursor/index.d.ts.map +1 -1
  46. package/dist/generators/gemini-cli/index.d.ts.map +1 -1
  47. package/dist/generators/github-copilot/index.d.ts.map +1 -1
  48. package/dist/generators/opencode/index.d.ts +1 -0
  49. package/dist/generators/opencode/index.d.ts.map +1 -1
  50. package/dist/generators/openhands/index.d.ts.map +1 -1
  51. package/dist/generators/roo-code/index.d.ts.map +1 -1
  52. package/dist/generators/shared/claude-family.d.ts.map +1 -1
  53. package/dist/generators/warp/index.d.ts.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +5371 -553
  57. package/dist/schema.d.ts +91 -42
  58. package/dist/schema.d.ts.map +1 -1
  59. package/dist/text-files.d.ts +5 -0
  60. package/dist/text-files.d.ts.map +1 -0
  61. package/dist/validation/platform-rules.d.ts +15 -1
  62. package/dist/validation/platform-rules.d.ts.map +1 -1
  63. package/package.json +15 -13
  64. package/src/cli/agent.ts +0 -1455
  65. package/src/cli/dev.ts +0 -112
  66. package/src/cli/doctor.ts +0 -987
  67. package/src/cli/eval.ts +0 -470
  68. package/src/cli/index.ts +0 -2933
  69. package/src/cli/init-from-mcp.ts +0 -2115
  70. package/src/cli/install.ts +0 -860
  71. package/src/cli/lint.ts +0 -1249
  72. package/src/cli/mcp-proxy.ts +0 -322
  73. package/src/cli/migrate.ts +0 -867
  74. package/src/cli/prompt.ts +0 -82
  75. package/src/cli/publish.ts +0 -401
  76. package/src/cli/runtime.ts +0 -86
  77. package/src/cli/sync-from-mcp.ts +0 -586
  78. package/src/cli/test.ts +0 -142
  79. package/src/compatibility/matrix.ts +0 -149
  80. package/src/config/define.ts +0 -20
  81. package/src/config/load.ts +0 -74
  82. package/src/generators/amp/index.ts +0 -63
  83. package/src/generators/base.ts +0 -188
  84. package/src/generators/claude-code/index.ts +0 -172
  85. package/src/generators/cline/index.ts +0 -35
  86. package/src/generators/codex/index.ts +0 -143
  87. package/src/generators/cursor/index.ts +0 -158
  88. package/src/generators/gemini-cli/index.ts +0 -83
  89. package/src/generators/github-copilot/index.ts +0 -32
  90. package/src/generators/hooks-warning.ts +0 -51
  91. package/src/generators/index.ts +0 -71
  92. package/src/generators/opencode/index.ts +0 -526
  93. package/src/generators/openhands/index.ts +0 -32
  94. package/src/generators/roo-code/index.ts +0 -35
  95. package/src/generators/shared/claude-family.ts +0 -215
  96. package/src/generators/warp/index.ts +0 -32
  97. package/src/hook-events.ts +0 -33
  98. package/src/index.ts +0 -34
  99. package/src/mcp/introspect.ts +0 -1107
  100. package/src/permissions.ts +0 -260
  101. package/src/schema.ts +0 -312
  102. package/src/user-config.ts +0 -177
  103. package/src/validation/platform-rules.ts +0 -686
@@ -1,1107 +0,0 @@
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.1',
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 IntrospectedMcpResource {
20
- uri: string
21
- name?: string
22
- title?: string
23
- description?: string
24
- mimeType?: string
25
- }
26
-
27
- export interface IntrospectedMcpResourceTemplate {
28
- uriTemplate: string
29
- name: string
30
- title?: string
31
- description?: string
32
- mimeType?: string
33
- }
34
-
35
- export interface IntrospectedMcpPrompt {
36
- name: string
37
- title?: string
38
- description?: string
39
- arguments?: Array<{
40
- name: string
41
- description?: string
42
- required?: boolean
43
- }>
44
- }
45
-
46
- export interface IntrospectedMcpServer {
47
- protocolVersion: string
48
- instructions?: string
49
- serverInfo: {
50
- name: string
51
- title?: string
52
- version?: string
53
- description?: string
54
- websiteUrl?: string
55
- }
56
- tools: IntrospectedMcpTool[]
57
- resources?: IntrospectedMcpResource[]
58
- resourceTemplates?: IntrospectedMcpResourceTemplate[]
59
- prompts?: IntrospectedMcpPrompt[]
60
- }
61
-
62
- export interface McpAuthDiscovery {
63
- kind: 'bearer' | 'header' | 'platform'
64
- mode?: 'oauth'
65
- headerName?: string
66
- authorizationUrl?: string
67
- resourceMetadataUrl?: string
68
- }
69
-
70
- export class McpIntrospectionError extends Error {
71
- constructor(
72
- message: string,
73
- readonly status?: number,
74
- readonly context?: {
75
- responseHeaders?: Record<string, string>
76
- responseBodySnippet?: string
77
- responseUrl?: string
78
- },
79
- readonly rpcCode?: number,
80
- ) {
81
- super(message)
82
- this.name = 'McpIntrospectionError'
83
- }
84
- }
85
-
86
- export function discoverMcpAuthFromError(error: McpIntrospectionError): McpAuthDiscovery | null {
87
- const wwwAuthenticate = error.context?.responseHeaders?.['www-authenticate']?.trim() ?? ''
88
- const locationHeader = error.context?.responseHeaders?.location?.trim()
89
- const responseUrl = error.context?.responseUrl?.trim()
90
- const responseBody = error.context?.responseBodySnippet ?? ''
91
- const message = error.message
92
-
93
- const authorizationUri = extractAuthParam(wwwAuthenticate, 'authorization_uri')
94
- const resourceMetadataUrl = extractAuthParam(wwwAuthenticate, 'resource_metadata')
95
- const oauthUrl = authorizationUri
96
- ?? pickOAuthUrl(locationHeader, responseUrl)
97
-
98
- if (looksLikeOAuthSignal([wwwAuthenticate, locationHeader, responseUrl, responseBody, message])) {
99
- return {
100
- kind: 'platform',
101
- mode: 'oauth',
102
- ...(oauthUrl ? { authorizationUrl: oauthUrl } : {}),
103
- ...(resourceMetadataUrl ? { resourceMetadataUrl } : {}),
104
- }
105
- }
106
-
107
- const headerName = extractHeaderName([wwwAuthenticate, responseBody, message])
108
- if (headerName && headerName.toLowerCase() !== 'authorization') {
109
- return {
110
- kind: 'header',
111
- headerName,
112
- }
113
- }
114
-
115
- if (wwwAuthenticate.toLowerCase().includes('bearer') || headerName === 'Authorization') {
116
- return {
117
- kind: 'bearer',
118
- headerName: 'Authorization',
119
- }
120
- }
121
-
122
- return null
123
- }
124
-
125
- interface JsonRpcSuccess<T> {
126
- jsonrpc: '2.0'
127
- id: number | string
128
- result: T
129
- }
130
-
131
- interface JsonRpcFailure {
132
- jsonrpc: '2.0'
133
- id: number | string | null
134
- error: {
135
- code: number
136
- message: string
137
- data?: unknown
138
- }
139
- }
140
-
141
- type JsonRpcEnvelope<T> = JsonRpcSuccess<T> | JsonRpcFailure
142
-
143
- interface InitializeResult {
144
- protocolVersion?: string
145
- instructions?: string
146
- capabilities?: {
147
- tools?: Record<string, unknown>
148
- resources?: Record<string, unknown>
149
- prompts?: Record<string, unknown>
150
- }
151
- serverInfo?: {
152
- name?: string
153
- title?: string
154
- version?: string
155
- description?: string
156
- websiteUrl?: string
157
- }
158
- }
159
-
160
- interface ListToolsResult {
161
- tools?: Array<{
162
- name: string
163
- title?: string
164
- description?: string
165
- inputSchema?: Record<string, unknown>
166
- }>
167
- nextCursor?: string
168
- }
169
-
170
- interface ListResourcesResult {
171
- resources?: Array<{
172
- uri: string
173
- name?: string
174
- title?: string
175
- description?: string
176
- mimeType?: string
177
- }>
178
- nextCursor?: string
179
- }
180
-
181
- interface ListResourceTemplatesResult {
182
- resourceTemplates?: Array<{
183
- uriTemplate: string
184
- name: string
185
- title?: string
186
- description?: string
187
- mimeType?: string
188
- }>
189
- nextCursor?: string
190
- }
191
-
192
- interface ListPromptsResult {
193
- prompts?: Array<{
194
- name: string
195
- title?: string
196
- description?: string
197
- arguments?: Array<{
198
- name: string
199
- description?: string
200
- required?: boolean
201
- }>
202
- }>
203
- nextCursor?: string
204
- }
205
-
206
- export interface McpClient {
207
- request<T>(method: string, params?: Record<string, unknown>): Promise<T>
208
- notify(method: string, params?: Record<string, unknown>): Promise<void>
209
- close(): Promise<void>
210
- }
211
-
212
- export async function introspectMcpServer(server: McpServer): Promise<IntrospectedMcpServer> {
213
- if (server.transport === 'http') {
214
- try {
215
- return await introspectWithClient(await createMcpClient(server))
216
- } catch (error) {
217
- if (
218
- error instanceof McpIntrospectionError
219
- && (error.status === 400 || error.status === 404 || error.status === 405)
220
- ) {
221
- return await introspectWithClient(await createSseClient({
222
- ...server,
223
- transport: 'sse',
224
- }))
225
- }
226
-
227
- throw error
228
- }
229
- }
230
-
231
- return await introspectWithClient(await createMcpClient(server))
232
- }
233
-
234
- export async function createMcpClient(server: McpServer): Promise<McpClient> {
235
- if (server.transport === 'stdio') {
236
- return await createStdioClient(server)
237
- }
238
-
239
- if (server.transport === 'sse') {
240
- return await createSseClient(server)
241
- }
242
-
243
- return createHttpClient(server)
244
- }
245
-
246
- async function introspectWithClient(client: McpClient): Promise<IntrospectedMcpServer> {
247
- try {
248
- const initialize = await client.request<InitializeResult>('initialize', {
249
- protocolVersion: MCP_PROTOCOL_VERSION,
250
- capabilities: {},
251
- clientInfo: CLIENT_INFO,
252
- })
253
-
254
- await client.notify('notifications/initialized')
255
-
256
- const tools = await listAllTools(client)
257
- if (tools.length === 0) {
258
- throw new McpIntrospectionError(
259
- 'The MCP server initialized successfully but exposed no tools. pluxx init --from-mcp currently scaffolds from tool metadata only.',
260
- )
261
- }
262
- const resources = hasInitializeCapability(initialize, 'resources')
263
- ? await listAllResources(client)
264
- : []
265
- const resourceTemplates = hasInitializeCapability(initialize, 'resources')
266
- ? await listAllResourceTemplates(client)
267
- : []
268
- const prompts = hasInitializeCapability(initialize, 'prompts')
269
- ? await listAllPrompts(client)
270
- : []
271
-
272
- return {
273
- protocolVersion: initialize.protocolVersion ?? MCP_PROTOCOL_VERSION,
274
- instructions: initialize.instructions,
275
- serverInfo: {
276
- name: initialize.serverInfo?.name ?? 'mcp-server',
277
- title: initialize.serverInfo?.title,
278
- version: initialize.serverInfo?.version,
279
- description: initialize.serverInfo?.description,
280
- websiteUrl: initialize.serverInfo?.websiteUrl,
281
- },
282
- tools,
283
- resources,
284
- resourceTemplates,
285
- prompts,
286
- }
287
- } finally {
288
- await client.close()
289
- }
290
- }
291
-
292
- function hasInitializeCapability(
293
- initialize: InitializeResult,
294
- capability: 'resources' | 'prompts',
295
- ): boolean {
296
- const value = initialize.capabilities?.[capability]
297
- return typeof value === 'object' && value !== null
298
- }
299
-
300
- function looksLikeOAuthSignal(values: Array<string | undefined>): boolean {
301
- return values.some((value) => {
302
- const normalized = value?.toLowerCase() ?? ''
303
- return /\boauth\b/.test(normalized)
304
- || normalized.includes('authorization_uri=')
305
- || /\bauthorize\b/.test(normalized)
306
- || /\blogin\b/.test(normalized)
307
- || /\bsignin\b/.test(normalized)
308
- })
309
- }
310
-
311
- function pickOAuthUrl(...candidates: Array<string | undefined>): string | undefined {
312
- for (const value of candidates) {
313
- if (!value) continue
314
- const normalized = value.toLowerCase()
315
- if (
316
- /\boauth\b/.test(normalized)
317
- || /\bauthorize\b/.test(normalized)
318
- || /\blogin\b/.test(normalized)
319
- || /\bsignin\b/.test(normalized)
320
- ) {
321
- return value
322
- }
323
- }
324
- return undefined
325
- }
326
-
327
- function extractAuthParam(header: string, name: string): string | undefined {
328
- if (!header) return undefined
329
- const pattern = new RegExp(`${name}=\"([^\"]+)\"`, 'i')
330
- return header.match(pattern)?.[1]
331
- }
332
-
333
- function extractHeaderName(values: string[]): string | undefined {
334
- for (const value of values) {
335
- const matches = value.match(/\b(?:x-[a-z0-9-]+|authorization)\b/gi)
336
- if (!matches) continue
337
- const candidate = matches[0]
338
- if (!candidate) continue
339
- if (candidate.toLowerCase() === 'authorization') {
340
- return 'Authorization'
341
- }
342
- return candidate
343
- .split('-')
344
- .map((part) => {
345
- const normalized = part.toLowerCase()
346
- if (normalized === 'api' || normalized === 'id' || normalized === 'url') {
347
- return normalized.toUpperCase()
348
- }
349
- return normalized.charAt(0).toUpperCase() + normalized.slice(1)
350
- })
351
- .join('-')
352
- }
353
- return undefined
354
- }
355
-
356
- async function listAllTools(client: McpClient): Promise<IntrospectedMcpTool[]> {
357
- const tools: IntrospectedMcpTool[] = []
358
- let cursor: string | undefined
359
-
360
- while (true) {
361
- const result = await client.request<ListToolsResult>('tools/list', cursor ? { cursor } : undefined)
362
- tools.push(...(result.tools ?? []))
363
- cursor = result.nextCursor
364
- if (!cursor) {
365
- return tools
366
- }
367
- }
368
- }
369
-
370
- async function listAllResources(client: McpClient): Promise<IntrospectedMcpResource[]> {
371
- return await listOptionalPaged(client, 'resources/list', (result: ListResourcesResult) => result.resources ?? [])
372
- }
373
-
374
- async function listAllResourceTemplates(client: McpClient): Promise<IntrospectedMcpResourceTemplate[]> {
375
- return await listOptionalPaged(
376
- client,
377
- 'resources/templates/list',
378
- (result: ListResourceTemplatesResult) => result.resourceTemplates ?? [],
379
- )
380
- }
381
-
382
- async function listAllPrompts(client: McpClient): Promise<IntrospectedMcpPrompt[]> {
383
- return await listOptionalPaged(client, 'prompts/list', (result: ListPromptsResult) => result.prompts ?? [])
384
- }
385
-
386
- async function listOptionalPaged<TResult extends { nextCursor?: string }, TItem>(
387
- client: McpClient,
388
- method: string,
389
- getItems: (result: TResult) => TItem[],
390
- ): Promise<TItem[]> {
391
- const items: TItem[] = []
392
- let cursor: string | undefined
393
-
394
- while (true) {
395
- let result: TResult
396
- try {
397
- result = await client.request<TResult>(method, cursor ? { cursor } : undefined)
398
- } catch (error) {
399
- if (isOptionalDiscoveryUnavailable(error)) {
400
- return items
401
- }
402
- throw error
403
- }
404
-
405
- items.push(...getItems(result))
406
- cursor = result.nextCursor
407
- if (!cursor) {
408
- return items
409
- }
410
- }
411
- }
412
-
413
- function isOptionalDiscoveryUnavailable(error: unknown): boolean {
414
- if (!(error instanceof McpIntrospectionError)) {
415
- return false
416
- }
417
-
418
- const message = error.message.toLowerCase()
419
- return error.rpcCode === -32601
420
- || message.includes('method not found')
421
- || message.includes('not supported')
422
- || message.includes('unsupported')
423
- }
424
-
425
- function createHttpClient(server: Exclude<McpServer, { transport: 'stdio' }>): McpClient {
426
- let sessionId: string | null = null
427
-
428
- return {
429
- async request<T>(method: string, params?: Record<string, unknown>): Promise<T> {
430
- const id = nextRequestId()
431
- const response = await fetch(server.url, {
432
- method: 'POST',
433
- headers: buildHttpHeaders(server.auth, sessionId),
434
- body: JSON.stringify({
435
- jsonrpc: '2.0',
436
- id,
437
- method,
438
- ...(params ? { params } : {}),
439
- }),
440
- })
441
-
442
- await throwIfLikelyAuthRedirect(response, 'MCP HTTP request was redirected to an authentication page.')
443
-
444
- if (!response.ok) {
445
- const context = await extractHttpErrorContext(response)
446
- throw new McpIntrospectionError(
447
- `MCP HTTP request failed with ${response.status} ${response.statusText}.`,
448
- response.status,
449
- context,
450
- )
451
- }
452
-
453
- sessionId = response.headers.get('Mcp-Session-Id') ?? sessionId
454
- const envelope = await parseHttpEnvelope<T>(response, id)
455
- return unwrapEnvelope(envelope)
456
- },
457
-
458
- async notify(method: string, params?: Record<string, unknown>): Promise<void> {
459
- const response = await fetch(server.url, {
460
- method: 'POST',
461
- headers: buildHttpHeaders(server.auth, sessionId),
462
- body: JSON.stringify({
463
- jsonrpc: '2.0',
464
- method,
465
- ...(params ? { params } : {}),
466
- }),
467
- })
468
-
469
- await throwIfLikelyAuthRedirect(response, 'MCP HTTP notification was redirected to an authentication page.')
470
-
471
- if (!response.ok && response.status !== 202) {
472
- const context = await extractHttpErrorContext(response)
473
- throw new McpIntrospectionError(
474
- `MCP HTTP notification failed with ${response.status} ${response.statusText}.`,
475
- response.status,
476
- context,
477
- )
478
- }
479
- },
480
-
481
- async close(): Promise<void> {
482
- if (!sessionId) return
483
- try {
484
- await fetch(server.url, {
485
- method: 'DELETE',
486
- headers: {
487
- 'Mcp-Session-Id': sessionId,
488
- 'Mcp-Protocol-Version': MCP_PROTOCOL_VERSION,
489
- },
490
- })
491
- } catch {
492
- // Session cleanup is best effort only.
493
- }
494
- },
495
- }
496
- }
497
-
498
- async function createSseClient(server: Extract<McpServer, { transport: 'sse' }>): Promise<McpClient> {
499
- let sessionId: string | null = null
500
- let endpointUrl: string | null = null
501
- let isClosed = false
502
- const pending = new Map<number, {
503
- resolve: (value: unknown) => void
504
- reject: (error: Error) => void
505
- timeout: ReturnType<typeof setTimeout>
506
- }>()
507
- const abortController = new AbortController()
508
- let resolveEndpoint!: (value: string) => void
509
- let rejectEndpoint!: (error: Error) => void
510
- let endpointSettled = false
511
-
512
- const endpointReady = new Promise<string>((resolve, reject) => {
513
- resolveEndpoint = (value) => {
514
- endpointSettled = true
515
- resolve(value)
516
- }
517
- rejectEndpoint = (error) => {
518
- endpointSettled = true
519
- reject(error)
520
- }
521
- })
522
-
523
- const streamPromise = (async () => {
524
- const response = await fetch(server.url, {
525
- method: 'GET',
526
- headers: buildSseStreamHeaders(server.auth, sessionId),
527
- signal: abortController.signal,
528
- })
529
-
530
- await throwIfLikelyAuthRedirect(response, 'MCP SSE stream was redirected to an authentication page.')
531
-
532
- if (!response.ok) {
533
- const context = await extractHttpErrorContext(response)
534
- throw new McpIntrospectionError(
535
- `MCP SSE stream failed with ${response.status} ${response.statusText}.`,
536
- response.status,
537
- context,
538
- )
539
- }
540
-
541
- const contentType = response.headers.get('content-type') ?? ''
542
- if (!contentType.includes('text/event-stream')) {
543
- throw new McpIntrospectionError(`Unsupported MCP SSE response content type: ${contentType || 'unknown'}.`)
544
- }
545
-
546
- sessionId = response.headers.get('Mcp-Session-Id') ?? sessionId
547
-
548
- if (!response.body) {
549
- throw new McpIntrospectionError('MCP SSE stream opened without a readable response body.')
550
- }
551
-
552
- const reader = response.body.getReader()
553
- const decoder = new TextDecoder()
554
- let buffer = ''
555
- let eventName = 'message'
556
- let dataLines: string[] = []
557
-
558
- const flushEvent = () => {
559
- if (dataLines.length === 0) {
560
- eventName = 'message'
561
- return
562
- }
563
-
564
- const data = dataLines.join('\n').trim()
565
- dataLines = []
566
- const currentEvent = eventName || 'message'
567
- eventName = 'message'
568
-
569
- if (!data) {
570
- return
571
- }
572
-
573
- if (currentEvent === 'endpoint') {
574
- endpointUrl = new URL(data, server.url).toString()
575
- if (!endpointSettled) {
576
- resolveEndpoint(endpointUrl)
577
- }
578
- return
579
- }
580
-
581
- if (currentEvent !== 'message') {
582
- return
583
- }
584
-
585
- const envelope = JSON.parse(data) as JsonRpcEnvelope<unknown>
586
- if (typeof envelope !== 'object' || envelope === null || !('id' in envelope)) {
587
- return
588
- }
589
-
590
- const requestId = typeof envelope.id === 'number' ? envelope.id : Number.NaN
591
- if (!Number.isFinite(requestId)) return
592
-
593
- const entry = pending.get(requestId)
594
- if (!entry) return
595
-
596
- pending.delete(requestId)
597
- clearTimeout(entry.timeout)
598
-
599
- try {
600
- entry.resolve(unwrapEnvelope(envelope))
601
- } catch (error) {
602
- entry.reject(error instanceof Error ? error : new Error(String(error)))
603
- }
604
- }
605
-
606
- while (true) {
607
- const { value, done } = await reader.read()
608
- if (done) break
609
-
610
- buffer += decoder.decode(value, { stream: true })
611
- const lines = buffer.split(/\r?\n/)
612
- buffer = lines.pop() ?? ''
613
-
614
- for (const line of lines) {
615
- if (line === '') {
616
- flushEvent()
617
- continue
618
- }
619
-
620
- if (line.startsWith(':')) {
621
- continue
622
- }
623
-
624
- const separatorIndex = line.indexOf(':')
625
- const field = separatorIndex === -1 ? line : line.slice(0, separatorIndex)
626
- const rawValue = separatorIndex === -1 ? '' : line.slice(separatorIndex + 1).trimStart()
627
-
628
- if (field === 'event') {
629
- eventName = rawValue || 'message'
630
- } else if (field === 'data') {
631
- dataLines.push(rawValue)
632
- }
633
- }
634
- }
635
-
636
- if (buffer) {
637
- const tailLines = buffer.split(/\r?\n/)
638
- for (const line of tailLines) {
639
- if (line.startsWith('data:')) {
640
- dataLines.push(line.slice(5).trimStart())
641
- }
642
- }
643
- }
644
- flushEvent()
645
-
646
- if (!endpointSettled) {
647
- rejectEndpoint(new McpIntrospectionError('MCP SSE stream did not provide the required endpoint event.'))
648
- return
649
- }
650
-
651
- if (!isClosed && pending.size > 0) {
652
- const error = new McpIntrospectionError('MCP SSE stream ended before pluxx finished introspecting it.')
653
- for (const entry of pending.values()) {
654
- clearTimeout(entry.timeout)
655
- entry.reject(error)
656
- }
657
- pending.clear()
658
- }
659
- })().catch((error) => {
660
- if (isClosed && error instanceof DOMException && error.name === 'AbortError') {
661
- return
662
- }
663
-
664
- const wrapped = error instanceof Error
665
- ? error
666
- : new McpIntrospectionError(String(error))
667
-
668
- if (!endpointSettled) {
669
- rejectEndpoint(wrapped)
670
- }
671
-
672
- for (const entry of pending.values()) {
673
- clearTimeout(entry.timeout)
674
- entry.reject(wrapped)
675
- }
676
- pending.clear()
677
- })
678
-
679
- await endpointReady
680
-
681
- return {
682
- async request<T>(method: string, params?: Record<string, unknown>): Promise<T> {
683
- const requestId = nextRequestId()
684
- const endpoint = endpointUrl ?? await endpointReady
685
-
686
- const resultPromise = new Promise<T>((resolve, reject) => {
687
- const timeout = setTimeout(() => {
688
- pending.delete(requestId)
689
- reject(new McpIntrospectionError(`Timed out waiting for MCP SSE response to ${method}.`))
690
- }, DEFAULT_TIMEOUT_MS)
691
-
692
- pending.set(requestId, {
693
- resolve: (value) => {
694
- clearTimeout(timeout)
695
- resolve(value as T)
696
- },
697
- reject: (error) => {
698
- clearTimeout(timeout)
699
- reject(error)
700
- },
701
- timeout,
702
- })
703
- })
704
-
705
- let response: Response
706
- try {
707
- response = await fetch(endpoint, {
708
- method: 'POST',
709
- headers: buildHttpHeaders(server.auth, sessionId),
710
- body: JSON.stringify({
711
- jsonrpc: '2.0',
712
- id: requestId,
713
- method,
714
- ...(params ? { params } : {}),
715
- }),
716
- })
717
- } catch (error) {
718
- const entry = pending.get(requestId)
719
- if (entry) {
720
- pending.delete(requestId)
721
- clearTimeout(entry.timeout)
722
- }
723
- throw new McpIntrospectionError(`MCP SSE request failed: ${error instanceof Error ? error.message : String(error)}`)
724
- }
725
-
726
- await throwIfLikelyAuthRedirect(response, 'MCP SSE request was redirected to an authentication page.')
727
-
728
- if (!response.ok && response.status !== 202) {
729
- const entry = pending.get(requestId)
730
- if (entry) {
731
- pending.delete(requestId)
732
- clearTimeout(entry.timeout)
733
- }
734
- const context = await extractHttpErrorContext(response)
735
- throw new McpIntrospectionError(
736
- `MCP SSE request failed with ${response.status} ${response.statusText}.`,
737
- response.status,
738
- context,
739
- )
740
- }
741
-
742
- sessionId = response.headers.get('Mcp-Session-Id') ?? sessionId
743
- const contentType = response.headers.get('content-type') ?? ''
744
- if (contentType.includes('application/json') || contentType.includes('text/event-stream')) {
745
- const entry = pending.get(requestId)
746
- if (entry) {
747
- pending.delete(requestId)
748
- clearTimeout(entry.timeout)
749
- }
750
- const envelope = await parseHttpEnvelope<T>(response, requestId)
751
- return unwrapEnvelope(envelope)
752
- }
753
-
754
- return await resultPromise
755
- },
756
-
757
- async notify(method: string, params?: Record<string, unknown>): Promise<void> {
758
- const endpoint = endpointUrl ?? await endpointReady
759
- const response = await fetch(endpoint, {
760
- method: 'POST',
761
- headers: buildHttpHeaders(server.auth, sessionId),
762
- body: JSON.stringify({
763
- jsonrpc: '2.0',
764
- method,
765
- ...(params ? { params } : {}),
766
- }),
767
- })
768
-
769
- await throwIfLikelyAuthRedirect(response, 'MCP SSE notification was redirected to an authentication page.')
770
-
771
- if (!response.ok && response.status !== 202) {
772
- const context = await extractHttpErrorContext(response)
773
- throw new McpIntrospectionError(
774
- `MCP SSE notification failed with ${response.status} ${response.statusText}.`,
775
- response.status,
776
- context,
777
- )
778
- }
779
-
780
- sessionId = response.headers.get('Mcp-Session-Id') ?? sessionId
781
- },
782
-
783
- async close(): Promise<void> {
784
- isClosed = true
785
- abortController.abort()
786
- await streamPromise
787
- },
788
- }
789
- }
790
-
791
- async function createStdioClient(server: Extract<McpServer, { transport: 'stdio' }>): Promise<McpClient> {
792
- const child = spawn(server.command, server.args ?? [], {
793
- env: {
794
- ...process.env,
795
- ...(server.env ?? {}),
796
- },
797
- stdio: ['pipe', 'pipe', 'pipe'],
798
- })
799
- let isClosing = false
800
-
801
- const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
802
- const stdout = readline.createInterface({
803
- input: child.stdout,
804
- crlfDelay: Infinity,
805
- })
806
-
807
- stdout.on('line', (line) => {
808
- if (!line.trim()) return
809
-
810
- let envelope: JsonRpcEnvelope<unknown>
811
- try {
812
- envelope = JSON.parse(line) as JsonRpcEnvelope<unknown>
813
- } catch {
814
- return
815
- }
816
-
817
- if (typeof envelope !== 'object' || envelope === null || !('id' in envelope)) {
818
- return
819
- }
820
-
821
- const requestId = typeof envelope.id === 'number' ? envelope.id : Number.NaN
822
- if (!Number.isFinite(requestId)) return
823
-
824
- const entry = pending.get(requestId)
825
- if (!entry) return
826
-
827
- pending.delete(requestId)
828
-
829
- try {
830
- entry.resolve(unwrapEnvelope(envelope))
831
- } catch (error) {
832
- entry.reject(error instanceof Error ? error : new Error(String(error)))
833
- }
834
- })
835
-
836
- child.once('exit', (code, signal) => {
837
- if (isClosing) {
838
- pending.clear()
839
- return
840
- }
841
- const error = new McpIntrospectionError(
842
- `MCP stdio process exited before pluxx finished introspecting it (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`,
843
- )
844
- for (const entry of pending.values()) {
845
- entry.reject(error)
846
- }
847
- pending.clear()
848
- })
849
-
850
- child.once('error', (error) => {
851
- const wrapped = new McpIntrospectionError(`Failed to start MCP stdio process: ${error.message}`)
852
- for (const entry of pending.values()) {
853
- entry.reject(wrapped)
854
- }
855
- pending.clear()
856
- })
857
-
858
- function send(message: Record<string, unknown>) {
859
- child.stdin.write(JSON.stringify(message) + '\n')
860
- }
861
-
862
- return {
863
- request<T>(method: string, params?: Record<string, unknown>): Promise<T> {
864
- const id = nextRequestId()
865
- send({
866
- jsonrpc: '2.0',
867
- id,
868
- method,
869
- ...(params ? { params } : {}),
870
- })
871
-
872
- return new Promise<T>((resolve, reject) => {
873
- const timeout = setTimeout(() => {
874
- pending.delete(id)
875
- reject(new McpIntrospectionError(`Timed out waiting for MCP stdio response to ${method}.`))
876
- }, DEFAULT_TIMEOUT_MS)
877
-
878
- pending.set(id, {
879
- resolve: (value) => {
880
- clearTimeout(timeout)
881
- resolve(value as T)
882
- },
883
- reject: (error) => {
884
- clearTimeout(timeout)
885
- reject(error)
886
- },
887
- })
888
- })
889
- },
890
-
891
- async notify(method: string, params?: Record<string, unknown>): Promise<void> {
892
- send({
893
- jsonrpc: '2.0',
894
- method,
895
- ...(params ? { params } : {}),
896
- })
897
- },
898
-
899
- async close(): Promise<void> {
900
- isClosing = true
901
- stdout.close()
902
- child.stdin.end()
903
- child.stdout.destroy()
904
- child.stderr?.destroy()
905
-
906
- if (child.exitCode === null && child.signalCode === null) {
907
- child.kill('SIGKILL')
908
- child.unref()
909
- }
910
- },
911
- }
912
- }
913
-
914
- function buildHttpHeaders(auth: McpAuth | undefined, sessionId: string | null): HeadersInit {
915
- const headers: Record<string, string> = {
916
- 'Content-Type': 'application/json',
917
- Accept: 'application/json, text/event-stream',
918
- 'Mcp-Protocol-Version': MCP_PROTOCOL_VERSION,
919
- }
920
-
921
- if (sessionId) {
922
- headers['Mcp-Session-Id'] = sessionId
923
- }
924
-
925
- const authHeader = resolveAuthHeader(auth)
926
- if (authHeader) {
927
- headers[authHeader.name] = authHeader.value
928
- }
929
-
930
- return headers
931
- }
932
-
933
- function buildSseStreamHeaders(auth: McpAuth | undefined, sessionId: string | null): HeadersInit {
934
- const headers: Record<string, string> = {
935
- Accept: 'text/event-stream',
936
- 'Mcp-Protocol-Version': MCP_PROTOCOL_VERSION,
937
- }
938
-
939
- if (sessionId) {
940
- headers['Mcp-Session-Id'] = sessionId
941
- }
942
-
943
- const authHeader = resolveAuthHeader(auth)
944
- if (authHeader) {
945
- headers[authHeader.name] = authHeader.value
946
- }
947
-
948
- return headers
949
- }
950
-
951
- function resolveAuthHeader(auth: McpAuth | undefined): { name: string; value: string } | null {
952
- if (!auth || auth.type === 'none') return null
953
- if (auth.type === 'platform') return null
954
-
955
- const envValue = process.env[auth.envVar]
956
- if (!envValue) {
957
- throw new McpIntrospectionError(
958
- `Missing environment variable ${auth.envVar} required to introspect the MCP server.`,
959
- )
960
- }
961
-
962
- const headerName = auth.type === 'bearer'
963
- ? auth.headerName ?? 'Authorization'
964
- : auth.headerName
965
- const headerTemplate = auth.headerTemplate ?? 'Bearer ${value}'
966
-
967
- return {
968
- name: headerName,
969
- value: headerTemplate.replace('${value}', envValue),
970
- }
971
- }
972
-
973
- async function extractHttpErrorContext(response: Response): Promise<{
974
- responseHeaders?: Record<string, string>
975
- responseBodySnippet?: string
976
- responseUrl?: string
977
- }> {
978
- const responseHeaders: Record<string, string> = {}
979
- for (const headerName of ['www-authenticate', 'location', 'content-type']) {
980
- const value = response.headers.get(headerName)
981
- if (value) {
982
- responseHeaders[headerName] = value
983
- }
984
- }
985
-
986
- let responseBodySnippet: string | undefined
987
- try {
988
- const contentType = response.headers.get('content-type') ?? ''
989
- if (contentType.includes('application/json') || contentType.includes('text/plain') || contentType.includes('text/html')) {
990
- const body = (await response.text()).trim()
991
- if (body) {
992
- responseBodySnippet = body.slice(0, 500)
993
- }
994
- }
995
- } catch {
996
- // Body extraction is best effort only.
997
- }
998
-
999
- const responseUrl = response.redirected && response.url ? response.url : undefined
1000
-
1001
- if (Object.keys(responseHeaders).length === 0 && !responseBodySnippet && !responseUrl) {
1002
- return {}
1003
- }
1004
-
1005
- return {
1006
- ...(Object.keys(responseHeaders).length > 0 ? { responseHeaders } : {}),
1007
- ...(responseBodySnippet ? { responseBodySnippet } : {}),
1008
- ...(responseUrl ? { responseUrl } : {}),
1009
- }
1010
- }
1011
-
1012
- function isLikelyAuthRedirectResponse(response: Response): boolean {
1013
- if (!response.redirected || !response.url) {
1014
- return false
1015
- }
1016
-
1017
- const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
1018
- const finalUrl = response.url.toLowerCase()
1019
-
1020
- return (contentType.includes('text/html') || contentType.includes('text/plain'))
1021
- && (
1022
- finalUrl.includes('oauth')
1023
- || finalUrl.includes('authorize')
1024
- || finalUrl.includes('login')
1025
- || finalUrl.includes('signin')
1026
- )
1027
- }
1028
-
1029
- async function throwIfLikelyAuthRedirect(response: Response, message: string): Promise<void> {
1030
- if (!isLikelyAuthRedirectResponse(response)) {
1031
- return
1032
- }
1033
-
1034
- const context = await extractHttpErrorContext(response)
1035
- throw new McpIntrospectionError(message, 401, context)
1036
- }
1037
-
1038
- async function parseHttpEnvelope<T>(
1039
- response: Response,
1040
- requestId: number,
1041
- ): Promise<JsonRpcEnvelope<T>> {
1042
- const contentType = response.headers.get('content-type') ?? ''
1043
-
1044
- if (contentType.includes('application/json')) {
1045
- return await response.json() as JsonRpcEnvelope<T>
1046
- }
1047
-
1048
- if (contentType.includes('text/event-stream')) {
1049
- const payload = await response.text()
1050
- const envelopes = parseSsePayload(payload)
1051
- const match = envelopes.find((message) => message.id === requestId) as JsonRpcEnvelope<T> | undefined
1052
- if (!match) {
1053
- throw new McpIntrospectionError(
1054
- `MCP server returned an SSE stream for request ${requestId} without a matching JSON-RPC response.`,
1055
- )
1056
- }
1057
- return match
1058
- }
1059
-
1060
- throw new McpIntrospectionError(`Unsupported MCP HTTP response content type: ${contentType || 'unknown'}.`)
1061
- }
1062
-
1063
- function parseSsePayload(payload: string): Array<JsonRpcEnvelope<unknown>> {
1064
- const messages: Array<JsonRpcEnvelope<unknown>> = []
1065
- let dataLines: string[] = []
1066
-
1067
- const flush = () => {
1068
- if (dataLines.length === 0) return
1069
- const data = dataLines.join('\n').trim()
1070
- dataLines = []
1071
- if (!data) return
1072
- messages.push(JSON.parse(data) as JsonRpcEnvelope<unknown>)
1073
- }
1074
-
1075
- for (const line of payload.split(/\r?\n/)) {
1076
- if (line === '') {
1077
- flush()
1078
- continue
1079
- }
1080
-
1081
- if (line.startsWith('data:')) {
1082
- dataLines.push(line.slice(5).trimStart())
1083
- }
1084
- }
1085
-
1086
- flush()
1087
- return messages
1088
- }
1089
-
1090
- function unwrapEnvelope<T>(envelope: JsonRpcEnvelope<T>): T {
1091
- if ('error' in envelope) {
1092
- throw new McpIntrospectionError(
1093
- `MCP request failed: ${envelope.error.message}`,
1094
- undefined,
1095
- undefined,
1096
- envelope.error.code,
1097
- )
1098
- }
1099
-
1100
- return envelope.result
1101
- }
1102
-
1103
- let requestCounter = 0
1104
- function nextRequestId(): number {
1105
- requestCounter += 1
1106
- return requestCounter
1107
- }