@orchid-labs/pluxx 0.1.1 → 0.1.4
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.
- package/README.md +25 -8
- package/bin/pluxx.js +19 -28
- package/dist/agents.d.ts +16 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/cli/agent.d.ts +62 -0
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/entry.d.ts +2 -0
- package/dist/cli/entry.d.ts.map +1 -0
- package/dist/cli/index.d.ts +7 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +21810 -0
- package/dist/cli/init-from-mcp.d.ts +17 -1
- package/dist/cli/init-from-mcp.d.ts.map +1 -1
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.d.ts.map +1 -1
- package/dist/cli/lint.d.ts +3 -1
- package/dist/cli/lint.d.ts.map +1 -1
- package/dist/cli/mcp-proxy.d.ts.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/primitive-summary.d.ts +14 -0
- package/dist/cli/primitive-summary.d.ts.map +1 -0
- package/dist/cli/prompt.d.ts +1 -1
- package/dist/cli/publish.d.ts +6 -1
- package/dist/cli/publish.d.ts.map +1 -1
- package/dist/cli/sync-from-mcp.d.ts.map +1 -1
- package/dist/cli/verify-install.d.ts +25 -0
- package/dist/cli/verify-install.d.ts.map +1 -0
- package/dist/commands.d.ts +10 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/compiler-intent.d.ts +165 -0
- package/dist/compiler-intent.d.ts.map +1 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/delegation.d.ts +11 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/generators/amp/index.d.ts.map +1 -1
- package/dist/generators/base.d.ts +5 -0
- package/dist/generators/base.d.ts.map +1 -1
- package/dist/generators/claude-code/index.d.ts.map +1 -1
- package/dist/generators/cline/index.d.ts.map +1 -1
- package/dist/generators/codex/index.d.ts +4 -0
- package/dist/generators/codex/index.d.ts.map +1 -1
- package/dist/generators/cursor/index.d.ts +1 -0
- package/dist/generators/cursor/index.d.ts.map +1 -1
- package/dist/generators/gemini-cli/index.d.ts.map +1 -1
- package/dist/generators/github-copilot/index.d.ts.map +1 -1
- package/dist/generators/opencode/index.d.ts +1 -0
- package/dist/generators/opencode/index.d.ts.map +1 -1
- package/dist/generators/openhands/index.d.ts.map +1 -1
- package/dist/generators/roo-code/index.d.ts.map +1 -1
- package/dist/generators/shared/claude-family.d.ts.map +1 -1
- package/dist/generators/warp/index.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5371 -553
- package/dist/schema.d.ts +91 -42
- package/dist/schema.d.ts.map +1 -1
- package/dist/text-files.d.ts +5 -0
- package/dist/text-files.d.ts.map +1 -0
- package/dist/validation/platform-rules.d.ts +15 -1
- package/dist/validation/platform-rules.d.ts.map +1 -1
- package/package.json +15 -13
- package/src/cli/agent.ts +0 -1455
- package/src/cli/dev.ts +0 -112
- package/src/cli/doctor.ts +0 -987
- package/src/cli/eval.ts +0 -470
- package/src/cli/index.ts +0 -2933
- package/src/cli/init-from-mcp.ts +0 -2115
- package/src/cli/install.ts +0 -860
- package/src/cli/lint.ts +0 -1249
- package/src/cli/mcp-proxy.ts +0 -322
- package/src/cli/migrate.ts +0 -867
- package/src/cli/prompt.ts +0 -82
- package/src/cli/publish.ts +0 -401
- package/src/cli/runtime.ts +0 -86
- package/src/cli/sync-from-mcp.ts +0 -586
- package/src/cli/test.ts +0 -142
- package/src/compatibility/matrix.ts +0 -149
- package/src/config/define.ts +0 -20
- package/src/config/load.ts +0 -74
- package/src/generators/amp/index.ts +0 -63
- package/src/generators/base.ts +0 -188
- package/src/generators/claude-code/index.ts +0 -172
- package/src/generators/cline/index.ts +0 -35
- package/src/generators/codex/index.ts +0 -143
- package/src/generators/cursor/index.ts +0 -158
- package/src/generators/gemini-cli/index.ts +0 -83
- package/src/generators/github-copilot/index.ts +0 -32
- package/src/generators/hooks-warning.ts +0 -51
- package/src/generators/index.ts +0 -71
- package/src/generators/opencode/index.ts +0 -526
- package/src/generators/openhands/index.ts +0 -32
- package/src/generators/roo-code/index.ts +0 -35
- package/src/generators/shared/claude-family.ts +0 -215
- package/src/generators/warp/index.ts +0 -32
- package/src/hook-events.ts +0 -33
- package/src/index.ts +0 -34
- package/src/mcp/introspect.ts +0 -1107
- package/src/permissions.ts +0 -260
- package/src/schema.ts +0 -312
- package/src/user-config.ts +0 -177
- package/src/validation/platform-rules.ts +0 -686
package/src/mcp/introspect.ts
DELETED
|
@@ -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
|
-
}
|