@mohou/mcp-client 1.0.20
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 +8 -0
- package/package.json +31 -0
- package/src/client.ts +305 -0
- package/src/codes.ts +29 -0
- package/src/config.ts +52 -0
- package/src/index.ts +15 -0
- package/src/references.ts +121 -0
- package/src/secrets.ts +81 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mohou/mcp-client",
|
|
3
|
+
"version": "1.0.20",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"lib/types",
|
|
15
|
+
"README.md",
|
|
16
|
+
"!**/*.tsbuildinfo"
|
|
17
|
+
],
|
|
18
|
+
"main": "./src/index.ts",
|
|
19
|
+
"types": "./src/index.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./src/index.ts",
|
|
23
|
+
"default": "./src/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
2
|
+
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
4
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
|
5
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
6
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
7
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
|
8
|
+
|
|
9
|
+
import { McpError } from './codes.ts'
|
|
10
|
+
import type { McpServerSpec } from './config.ts'
|
|
11
|
+
import { mayHoldCredential } from './secrets.ts'
|
|
12
|
+
|
|
13
|
+
/** Reconnects after a live session drops. Not a locked number. */
|
|
14
|
+
const DEFAULT_RECONNECT_BUDGET = 2
|
|
15
|
+
|
|
16
|
+
interface ToolResult {
|
|
17
|
+
content?: Array<{ type: string; text?: string }>
|
|
18
|
+
structuredContent?: unknown
|
|
19
|
+
isError?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Session {
|
|
23
|
+
client: Client
|
|
24
|
+
generation: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* External MCP sessions. The first call opens a server. One session per id.
|
|
29
|
+
* A dropped live session reconnects until the budget is spent, then that call fails.
|
|
30
|
+
* The next call may open the server again.
|
|
31
|
+
* The set can be replaced while running: {@link setServers} closes the sessions it retires.
|
|
32
|
+
* This client does not register tools on a model. It does not unwrap `{ input: string }`.
|
|
33
|
+
*/
|
|
34
|
+
export class McpClient {
|
|
35
|
+
private readonly servers = new Map<string, McpServerSpec>()
|
|
36
|
+
private readonly sessions = new Map<string, Session>()
|
|
37
|
+
private readonly opening = new Map<string, Promise<Session>>()
|
|
38
|
+
private readonly generation = new Map<string, number>()
|
|
39
|
+
private readonly reconnects = new Map<string, number>()
|
|
40
|
+
private readonly dropped = new Set<string>()
|
|
41
|
+
private readonly recovering = new Set<string>()
|
|
42
|
+
private readonly parentEnv: NodeJS.ProcessEnv
|
|
43
|
+
private readonly reconnectBudget: number
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param servers - resolved specs. This client copies the map so exhaustion can drop one id.
|
|
47
|
+
* @param parentEnv - ambient environment; credential-shaped names are not forwarded
|
|
48
|
+
* @param reconnectBudget - how many times a live session may be replaced. Not locked.
|
|
49
|
+
*/
|
|
50
|
+
constructor(
|
|
51
|
+
servers: Record<string, McpServerSpec>,
|
|
52
|
+
parentEnv: NodeJS.ProcessEnv = process.env,
|
|
53
|
+
reconnectBudget = DEFAULT_RECONNECT_BUDGET,
|
|
54
|
+
) {
|
|
55
|
+
this.parentEnv = parentEnv
|
|
56
|
+
this.reconnectBudget = reconnectBudget
|
|
57
|
+
for (const [id, spec] of Object.entries(servers)) this.servers.set(id, spec)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Call one tool. Args are the tool's own object.
|
|
62
|
+
* @param serverId - key in the resolved config
|
|
63
|
+
* @param toolName - the server's tool name
|
|
64
|
+
* @param args - tool arguments; omitted means an empty object
|
|
65
|
+
*/
|
|
66
|
+
async call(serverId: string, toolName: string, args?: Record<string, unknown>): Promise<unknown> {
|
|
67
|
+
this.beginAttempt(serverId)
|
|
68
|
+
const result = await this.withSession(serverId, client => (
|
|
69
|
+
client.callTool({ name: toolName, arguments: args ?? {} }) as Promise<ToolResult>
|
|
70
|
+
))
|
|
71
|
+
if (result.isError === true) {
|
|
72
|
+
const spec = this.servers.get(serverId)
|
|
73
|
+
const text = textOf(result)
|
|
74
|
+
throw new McpError('mcp-tool-failed', this.redact(text || 'mcp tool failed', spec), { cause: result })
|
|
75
|
+
}
|
|
76
|
+
if (result.structuredContent !== undefined) return result.structuredContent
|
|
77
|
+
if (result.content !== undefined && result.content.every(block => block.type === 'text')) return textOf(result)
|
|
78
|
+
return result.content ?? null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Server ids still registered. Does not open a connection. */
|
|
82
|
+
serverIds(): string[] {
|
|
83
|
+
return [...this.servers.keys()]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Replace the whole set. An id whose spec is deep-equal keeps its session and its budget.
|
|
88
|
+
* A removed id, or one whose spec changed, closes its session: the next call opens the new spec.
|
|
89
|
+
* The set is switched before any session closes, so a call that starts during this sees the new set.
|
|
90
|
+
* @param servers - resolved specs; the client copies the map
|
|
91
|
+
*/
|
|
92
|
+
async setServers(servers: Record<string, McpServerSpec>): Promise<void> {
|
|
93
|
+
const retired = [...this.servers].filter(([id, spec]) => {
|
|
94
|
+
const next = servers[id]
|
|
95
|
+
return next === undefined || !isDeepStrictEqual(spec, next)
|
|
96
|
+
})
|
|
97
|
+
this.servers.clear()
|
|
98
|
+
for (const [id, spec] of Object.entries(servers)) this.servers.set(id, spec)
|
|
99
|
+
for (const [id] of retired) await this.closeServer(id)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* List tools on one server. Opens the session if needed.
|
|
104
|
+
* @param serverId - key in the resolved config
|
|
105
|
+
*/
|
|
106
|
+
async listTools(serverId: string): Promise<Array<{
|
|
107
|
+
name: string
|
|
108
|
+
description?: string
|
|
109
|
+
inputSchema: Record<string, unknown>
|
|
110
|
+
outputSchema?: Record<string, unknown>
|
|
111
|
+
}>> {
|
|
112
|
+
this.beginAttempt(serverId)
|
|
113
|
+
return this.withSession(serverId, async (client) => {
|
|
114
|
+
const tools: Array<{
|
|
115
|
+
name: string
|
|
116
|
+
description?: string
|
|
117
|
+
inputSchema: Record<string, unknown>
|
|
118
|
+
outputSchema?: Record<string, unknown>
|
|
119
|
+
}> = []
|
|
120
|
+
let cursor: string | undefined
|
|
121
|
+
do {
|
|
122
|
+
const page = await client.listTools(cursor === undefined ? undefined : { cursor }) as {
|
|
123
|
+
tools: Array<{
|
|
124
|
+
name: string
|
|
125
|
+
description?: string
|
|
126
|
+
inputSchema: Record<string, unknown>
|
|
127
|
+
outputSchema?: Record<string, unknown>
|
|
128
|
+
}>
|
|
129
|
+
nextCursor?: string
|
|
130
|
+
}
|
|
131
|
+
tools.push(...page.tools)
|
|
132
|
+
cursor = page.nextCursor
|
|
133
|
+
} while (cursor !== undefined)
|
|
134
|
+
return tools
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Close every session and wait for stdio children to exit. */
|
|
139
|
+
async dispose(): Promise<void> {
|
|
140
|
+
const sessions = [...this.sessions.values()]
|
|
141
|
+
this.sessions.clear()
|
|
142
|
+
this.generation.clear()
|
|
143
|
+
await Promise.all(sessions.map(session => session.client.close().catch(() => undefined)))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async withSession<T>(serverId: string, work: (client: Client) => Promise<T>): Promise<T> {
|
|
147
|
+
for (;;) {
|
|
148
|
+
const spec = this.servers.get(serverId)
|
|
149
|
+
if (spec === undefined) throw new McpError('mcp-not-connected', `mcp server is not connected: ${serverId}`)
|
|
150
|
+
if (this.dropped.has(serverId) && !this.allowReconnect(serverId)) {
|
|
151
|
+
throw new McpError('mcp-start-failed', `mcp server stopped: ${serverId}`)
|
|
152
|
+
}
|
|
153
|
+
const session = await this.session(serverId, spec)
|
|
154
|
+
try {
|
|
155
|
+
const value = await work(session.client)
|
|
156
|
+
this.reconnects.delete(serverId)
|
|
157
|
+
return value
|
|
158
|
+
} catch (error) {
|
|
159
|
+
this.recovering.add(serverId)
|
|
160
|
+
await this.closeGeneration(session)
|
|
161
|
+
this.recovering.delete(serverId)
|
|
162
|
+
this.dropped.delete(serverId)
|
|
163
|
+
if (isClosed(error) && this.spendReconnect(serverId)) continue
|
|
164
|
+
throw new McpError(
|
|
165
|
+
isClosed(error) ? 'mcp-start-failed' : 'mcp-tool-failed',
|
|
166
|
+
this.redact(error instanceof Error ? error.message : 'mcp tool failed', spec),
|
|
167
|
+
{ cause: error },
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private beginAttempt(serverId: string): void {
|
|
174
|
+
this.reconnects.delete(serverId)
|
|
175
|
+
this.dropped.delete(serverId)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private allowReconnect(serverId: string): boolean {
|
|
179
|
+
this.dropped.delete(serverId)
|
|
180
|
+
return this.spendReconnect(serverId)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private spendReconnect(serverId: string): boolean {
|
|
184
|
+
const used = (this.reconnects.get(serverId) ?? 0) + 1
|
|
185
|
+
this.reconnects.set(serverId, used)
|
|
186
|
+
return used <= this.reconnectBudget
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private session(serverId: string, spec: McpServerSpec): Promise<Session> {
|
|
190
|
+
const live = this.sessions.get(serverId)
|
|
191
|
+
if (live !== undefined) return Promise.resolve(live)
|
|
192
|
+
const pending = this.opening.get(serverId)
|
|
193
|
+
if (pending !== undefined) return pending
|
|
194
|
+
const opening = this.open(serverId, spec).then((session) => {
|
|
195
|
+
this.opening.delete(serverId)
|
|
196
|
+
// The set may have been replaced or disposed while this was opening. Do not adopt it.
|
|
197
|
+
if (this.generation.get(serverId) !== session.generation) {
|
|
198
|
+
void session.client.close().catch(() => undefined)
|
|
199
|
+
throw new McpError('mcp-not-connected', `mcp server was replaced while opening: ${serverId}`)
|
|
200
|
+
}
|
|
201
|
+
this.sessions.set(serverId, session)
|
|
202
|
+
return session
|
|
203
|
+
}, (error: unknown) => {
|
|
204
|
+
this.opening.delete(serverId)
|
|
205
|
+
throw error
|
|
206
|
+
})
|
|
207
|
+
this.opening.set(serverId, opening)
|
|
208
|
+
return opening
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async open(serverId: string, spec: McpServerSpec): Promise<Session> {
|
|
212
|
+
const generation = (this.generation.get(serverId) ?? 0) + 1
|
|
213
|
+
this.generation.set(serverId, generation)
|
|
214
|
+
const client = new Client({ name: 'mini-app', version: '0.0.0' })
|
|
215
|
+
client.onclose = () => {
|
|
216
|
+
if (this.generation.get(serverId) !== generation) return
|
|
217
|
+
const current = this.sessions.get(serverId)
|
|
218
|
+
if (current?.generation === generation) this.sessions.delete(serverId)
|
|
219
|
+
if (!this.recovering.has(serverId)) this.dropped.add(serverId)
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await client.connect(transportFor(spec, scrubEnv(this.parentEnv)) as Transport)
|
|
223
|
+
return { client, generation }
|
|
224
|
+
} catch (error) {
|
|
225
|
+
await client.close().catch(() => undefined)
|
|
226
|
+
throw new McpError('mcp-start-failed', this.redact(error instanceof Error ? error.message : 'mcp server did not start', spec), { cause: error })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async closeGeneration(session: Session): Promise<void> {
|
|
231
|
+
const current = [...this.sessions.entries()].find(([, live]) => live === session)
|
|
232
|
+
if (current !== undefined) this.sessions.delete(current[0])
|
|
233
|
+
await session.client.close().catch(() => undefined)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Retire one id: invalidate its generation, wait for an open in flight, close the live session,
|
|
238
|
+
* and forget its reconnect budget. The next call opens the spec this client holds now.
|
|
239
|
+
*/
|
|
240
|
+
private async closeServer(serverId: string): Promise<void> {
|
|
241
|
+
this.generation.set(serverId, (this.generation.get(serverId) ?? 0) + 1)
|
|
242
|
+
const pending = this.opening.get(serverId)
|
|
243
|
+
if (pending !== undefined) await pending.catch(() => undefined)
|
|
244
|
+
const live = this.sessions.get(serverId)
|
|
245
|
+
if (live !== undefined) {
|
|
246
|
+
this.sessions.delete(serverId)
|
|
247
|
+
await live.client.close().catch(() => undefined)
|
|
248
|
+
}
|
|
249
|
+
this.reconnects.delete(serverId)
|
|
250
|
+
this.dropped.delete(serverId)
|
|
251
|
+
this.recovering.delete(serverId)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private redact(message: string, spec: McpServerSpec | undefined): string {
|
|
255
|
+
if (spec === undefined) return message
|
|
256
|
+
const secrets = specHasEnv(spec) ? Object.values(spec.env) : []
|
|
257
|
+
let text = message
|
|
258
|
+
for (const secret of secrets) {
|
|
259
|
+
if (secret.length < 4) continue
|
|
260
|
+
text = text.split(secret).join('[redacted]')
|
|
261
|
+
}
|
|
262
|
+
return text
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isClosed(error: unknown): boolean {
|
|
267
|
+
if (!(error instanceof Error)) return false
|
|
268
|
+
return error.message === 'Connection closed'
|
|
269
|
+
|| error.message === 'Not connected'
|
|
270
|
+
|| error.message.endsWith(': Connection closed')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function transportFor(spec: McpServerSpec, parent: Record<string, string>) {
|
|
274
|
+
if ('url' in spec) {
|
|
275
|
+
const url = new URL(spec.url)
|
|
276
|
+
if (spec.transport === 'sse') {
|
|
277
|
+
// Some servers still speak SSE. The contract keeps that transport.
|
|
278
|
+
// oxlint-disable-next-line typescript/no-deprecated
|
|
279
|
+
return new SSEClientTransport(url)
|
|
280
|
+
}
|
|
281
|
+
return new StreamableHTTPClientTransport(url, spec.headers === undefined ? undefined : { requestInit: { headers: spec.headers } })
|
|
282
|
+
}
|
|
283
|
+
return new StdioClientTransport({
|
|
284
|
+
command: spec.command,
|
|
285
|
+
args: spec.args ?? [],
|
|
286
|
+
env: { ...parent, ...spec.env },
|
|
287
|
+
stderr: 'pipe',
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function scrubEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
292
|
+
const next: Record<string, string> = {}
|
|
293
|
+
for (const [key, value] of Object.entries(env)) {
|
|
294
|
+
if (value !== undefined && !mayHoldCredential(key)) next[key] = value
|
|
295
|
+
}
|
|
296
|
+
return next
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function specHasEnv(spec: McpServerSpec): spec is McpServerSpec & { env: Record<string, string> } {
|
|
300
|
+
return 'command' in spec && spec.env !== undefined
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function textOf(result: ToolResult): string {
|
|
304
|
+
return (result.content ?? []).flatMap(block => block.text === undefined ? [] : [block.text]).join('\n')
|
|
305
|
+
}
|
package/src/codes.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Codes the MCP client emits. */
|
|
2
|
+
|
|
3
|
+
export const mcpCodes = [
|
|
4
|
+
'mcp-not-connected',
|
|
5
|
+
'mcp-start-failed',
|
|
6
|
+
'mcp-tool-failed',
|
|
7
|
+
'config-invalid',
|
|
8
|
+
'mcp-reference-unknown',
|
|
9
|
+
'mcp-reference-invalid',
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
/** An MCP client failure. Callers match `code`. */
|
|
13
|
+
export type McpCode = (typeof mcpCodes)[number]
|
|
14
|
+
|
|
15
|
+
/** Failure from `ctx.mcp`. The message is for a person. Secrets are not included. */
|
|
16
|
+
export class McpError extends Error {
|
|
17
|
+
readonly code: McpCode
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param code - one of {@link mcpCodes}
|
|
21
|
+
* @param message - human text; not the match key
|
|
22
|
+
* @param options - optional `cause`
|
|
23
|
+
*/
|
|
24
|
+
constructor(code: McpCode, message: string, options?: { cause?: unknown }) {
|
|
25
|
+
super(message, options)
|
|
26
|
+
this.name = 'McpError'
|
|
27
|
+
this.code = code
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { McpError } from './codes.ts'
|
|
2
|
+
|
|
3
|
+
/** One configured external server. */
|
|
4
|
+
export type McpServerSpec =
|
|
5
|
+
| { command: string; args?: string[]; env?: Record<string, string> }
|
|
6
|
+
| { url: string; transport?: 'sse' | 'streamable-http'; headers?: Record<string, string> }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Admit `mcp.json`. A missing value is zero servers. A present invalid value throws.
|
|
10
|
+
* An `mcpServers` wrapper is accepted. An entry named `settings` is skipped.
|
|
11
|
+
* @param value - parsed JSON, or `undefined` when the file is absent
|
|
12
|
+
*/
|
|
13
|
+
export function resolveMcpConfig(value: unknown): Record<string, McpServerSpec> {
|
|
14
|
+
if (value === undefined) return {}
|
|
15
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
16
|
+
throw new McpError('config-invalid', 'mcp.json must be an object')
|
|
17
|
+
}
|
|
18
|
+
const record = value as Record<string, unknown>
|
|
19
|
+
const source = isObject(record.mcpServers) ? record.mcpServers : record
|
|
20
|
+
const servers: Record<string, McpServerSpec> = {}
|
|
21
|
+
for (const [id, entry] of Object.entries(source)) {
|
|
22
|
+
if (id === 'settings') continue
|
|
23
|
+
if (!isObject(entry)) throw new McpError('config-invalid', `mcp server ${id} must be an object`)
|
|
24
|
+
if (typeof entry.command === 'string') {
|
|
25
|
+
servers[id] = {
|
|
26
|
+
command: entry.command,
|
|
27
|
+
...Array.isArray(entry.args) ? { args: entry.args.filter((item): item is string => typeof item === 'string') } : {},
|
|
28
|
+
...isStringRecord(entry.env) ? { env: entry.env } : {},
|
|
29
|
+
}
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (typeof entry.url === 'string') {
|
|
33
|
+
const transport = entry.transport === 'sse' || entry.transport === 'streamable-http' ? entry.transport : undefined
|
|
34
|
+
servers[id] = {
|
|
35
|
+
url: entry.url,
|
|
36
|
+
...transport === undefined ? {} : { transport },
|
|
37
|
+
...isStringRecord(entry.headers) ? { headers: entry.headers } : {},
|
|
38
|
+
}
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
throw new McpError('config-invalid', `mcp server ${id} needs a command or a url`)
|
|
42
|
+
}
|
|
43
|
+
return servers
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
47
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
51
|
+
return isObject(value) && Object.values(value).every(item => typeof item === 'string')
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** External MCP client. Not a model provider. @module @mohou/mcp-client */
|
|
2
|
+
|
|
3
|
+
export const packageId = '@mohou/mcp-client' as const
|
|
4
|
+
|
|
5
|
+
export { McpError, mcpCodes, type McpCode } from './codes.ts'
|
|
6
|
+
export { resolveMcpConfig, type McpServerSpec } from './config.ts'
|
|
7
|
+
export {
|
|
8
|
+
mcpReferenceKinds,
|
|
9
|
+
resolveServerMap,
|
|
10
|
+
resolveServerValues,
|
|
11
|
+
type McpReferenceKind,
|
|
12
|
+
type McpReferenceSources,
|
|
13
|
+
} from './references.ts'
|
|
14
|
+
export { McpClient } from './client.ts'
|
|
15
|
+
export { isCredential, looksLikeCredential, maskCredential, mayHoldCredential, namesCredential } from './secrets.ts'
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { McpError } from './codes.ts'
|
|
2
|
+
import type { McpServerSpec } from './config.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One named value a reference can name, or `undefined` when nothing holds that name.
|
|
6
|
+
* A credential provider is read per call, so a lookup may be async.
|
|
7
|
+
*/
|
|
8
|
+
export type McpReferenceLookup = (name: string) => string | undefined | Promise<string | undefined>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where a reference gets its value. Two lookups, bound by the caller: the ambient environment and
|
|
12
|
+
* whatever holds the credentials. This module knows no file, no keychain, and no provider package,
|
|
13
|
+
* so another credential implementation replaces the binding and not the resolver.
|
|
14
|
+
*/
|
|
15
|
+
export interface McpReferenceSources {
|
|
16
|
+
env: McpReferenceLookup
|
|
17
|
+
credential: McpReferenceLookup
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Kinds a value may reference. A reference that is neither is literal text. */
|
|
21
|
+
export const mcpReferenceKinds = ['env', 'credential'] as const
|
|
22
|
+
|
|
23
|
+
export type McpReferenceKind = (typeof mcpReferenceKinds)[number]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Replace every `${env:NAME}` and `${credential:NAME}` in one server spec.
|
|
27
|
+
* A spec without a reference comes back equal to its input; the input is never changed.
|
|
28
|
+
* The config file keeps the references. Only the copy handed to the client carries values.
|
|
29
|
+
* @param spec - one admitted server spec
|
|
30
|
+
* @param sources - the lookups the caller binds
|
|
31
|
+
* @param label - server id, used in a failure message only
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveServerValues(
|
|
34
|
+
spec: McpServerSpec,
|
|
35
|
+
sources: McpReferenceSources,
|
|
36
|
+
label?: string,
|
|
37
|
+
): Promise<McpServerSpec> {
|
|
38
|
+
if ('command' in spec) {
|
|
39
|
+
const args = spec.args === undefined
|
|
40
|
+
? {}
|
|
41
|
+
: { args: await Promise.all(spec.args.map((item, index) => replace(item, `args[${index}]`, sources, label))) }
|
|
42
|
+
return {
|
|
43
|
+
...spec,
|
|
44
|
+
command: await replace(spec.command, 'command', sources, label),
|
|
45
|
+
...args,
|
|
46
|
+
...spec.env === undefined ? {} : { env: await replaceRecord(spec.env, 'env', sources, label) },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
...spec,
|
|
51
|
+
url: await replace(spec.url, 'url', sources, label),
|
|
52
|
+
...spec.headers === undefined ? {} : { headers: await replaceRecord(spec.headers, 'headers', sources, label) },
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve every server in one map. The label of a failure is that server's id.
|
|
58
|
+
* @param servers - admitted specs, keyed by server id
|
|
59
|
+
* @param sources - the lookups the caller binds
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveServerMap(
|
|
62
|
+
servers: Record<string, McpServerSpec>,
|
|
63
|
+
sources: McpReferenceSources,
|
|
64
|
+
): Promise<Record<string, McpServerSpec>> {
|
|
65
|
+
const entries = await Promise.all(Object.entries(servers)
|
|
66
|
+
.map(async ([id, spec]) => [id, await resolveServerValues(spec, sources, id)] as const))
|
|
67
|
+
return Object.fromEntries(entries)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function replaceRecord(
|
|
71
|
+
record: Record<string, string>,
|
|
72
|
+
field: string,
|
|
73
|
+
sources: McpReferenceSources,
|
|
74
|
+
label: string | undefined,
|
|
75
|
+
): Promise<Record<string, string>> {
|
|
76
|
+
const entries = await Promise.all(Object.entries(record).map(async ([key, value]) => {
|
|
77
|
+
return [key, await replace(value, `${field}.${key}`, sources, label)] as const
|
|
78
|
+
}))
|
|
79
|
+
return Object.fromEntries(entries)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function replace(text: string, where: string, sources: McpReferenceSources, label: string | undefined): Promise<string> {
|
|
83
|
+
if (!text.includes('${')) return text
|
|
84
|
+
const whole = /\$\{(env|credential):([^}]*)\}/g
|
|
85
|
+
const opening = /\$\{(env|credential):/
|
|
86
|
+
let out = ''
|
|
87
|
+
let index = 0
|
|
88
|
+
let match: RegExpExecArray | null
|
|
89
|
+
while ((match = whole.exec(text)) !== null) {
|
|
90
|
+
if (opening.test(text.slice(index, match.index))) throw invalid(label, where)
|
|
91
|
+
out += text.slice(index, match.index)
|
|
92
|
+
out += await value(match[1] as McpReferenceKind, match[2]?.trim() ?? '', where, sources, label)
|
|
93
|
+
index = match.index + match[0].length
|
|
94
|
+
}
|
|
95
|
+
if (opening.test(text.slice(index))) throw invalid(label, where)
|
|
96
|
+
return out + text.slice(index)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A value that is absent, or empty, fails the load. An empty token is worse than a boot error. */
|
|
100
|
+
async function value(
|
|
101
|
+
kind: McpReferenceKind,
|
|
102
|
+
name: string,
|
|
103
|
+
where: string,
|
|
104
|
+
sources: McpReferenceSources,
|
|
105
|
+
label: string | undefined,
|
|
106
|
+
): Promise<string> {
|
|
107
|
+
if (name.length === 0) throw invalid(label, where)
|
|
108
|
+
const found = await (kind === 'env' ? sources.env(name) : sources.credential(name))
|
|
109
|
+
if (found !== undefined && found.length > 0) return found
|
|
110
|
+
throw new McpError('mcp-reference-unknown', kind === 'env'
|
|
111
|
+
? `${at(label, where)} names an environment variable that is not set: ${name}`
|
|
112
|
+
: `${at(label, where)} names an unknown credential: ${name}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function invalid(label: string | undefined, where: string): McpError {
|
|
116
|
+
return new McpError('mcp-reference-invalid', `${at(label, where)} has a malformed reference`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function at(label: string | undefined, where: string): string {
|
|
120
|
+
return label === undefined ? where : `${label} ${where}`
|
|
121
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Credential detection and masking for MCP config values. One home, so every caller agrees. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Broad name test for handing the ambient environment to a child process.
|
|
5
|
+
* A false positive only withholds one variable; a false negative leaks a key to a third-party
|
|
6
|
+
* server. Callers that display a value use {@link namesCredential} instead.
|
|
7
|
+
*/
|
|
8
|
+
const BROAD_NAME = /KEY|SECRET|TOKEN|PASSWORD/i
|
|
9
|
+
|
|
10
|
+
/** A whole segment of the name says credential. camelCase splits at its case boundary first. */
|
|
11
|
+
const CREDENTIAL_WORDS = [
|
|
12
|
+
'KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'PASSWD', 'CREDENTIAL', 'CREDENTIALS',
|
|
13
|
+
'AUTH', 'AUTHORIZATION', 'PRIVATE', 'SIGNATURE', 'COOKIE', 'SESSION',
|
|
14
|
+
]
|
|
15
|
+
const CREDENTIAL_SEGMENT = new RegExp(`(^|_)(${CREDENTIAL_WORDS.join('|')})(_|$)`)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A leading run that names the kind of credential: the scheme of the value, or the issuer's own
|
|
19
|
+
* prefix. None of it is secret, so a masked value keeps it and the caller still reads what it holds.
|
|
20
|
+
*/
|
|
21
|
+
const CREDENTIAL_LABELS = [
|
|
22
|
+
'bearer\\s+', 'basic\\s+', 'token\\s+', 'apikey\\s+', 'sk-', 'pk-', 'rk-', 'ghp_', 'gho_', 'ghu_',
|
|
23
|
+
'ghs_', 'github_pat_', 'glpat-', 'xox[abprs]-', 'AKIA', 'ASIA', 'eyJ', '-----BEGIN[A-Z ]*-----',
|
|
24
|
+
]
|
|
25
|
+
const CREDENTIAL_LABEL = new RegExp(`^(${CREDENTIAL_LABELS.join('|')})`, 'i')
|
|
26
|
+
|
|
27
|
+
/** The shape that carries no label, so only its length gives it away. */
|
|
28
|
+
const LONG_HEX = /^[0-9a-f]{32,}$/i
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ambient name test for a child's environment. Broad on purpose: see {@link BROAD_NAME}.
|
|
32
|
+
* @param name - environment variable name
|
|
33
|
+
*/
|
|
34
|
+
export function mayHoldCredential(name: string): boolean {
|
|
35
|
+
return BROAD_NAME.test(name)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a whole segment of this name says credential. `GITHUB_TOKEN`, `x-api-key`, `apiKey`, and
|
|
40
|
+
* `DB_PASSWORD` pass; `NODE_ENV`, `MONKEY`, and `AUTHOR` do not, so masking them hides nothing.
|
|
41
|
+
* @param name - environment variable or header name
|
|
42
|
+
*/
|
|
43
|
+
export function namesCredential(name: string): boolean {
|
|
44
|
+
return CREDENTIAL_SEGMENT.test(normalizeName(name))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the value's own shape is a credential, whatever its name says.
|
|
49
|
+
* @param value - the value to judge
|
|
50
|
+
*/
|
|
51
|
+
export function looksLikeCredential(value: string): boolean {
|
|
52
|
+
return CREDENTIAL_LABEL.test(value) || LONG_HEX.test(value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether this entry is a credential, and so must not travel back to a caller in the clear.
|
|
57
|
+
* Either signal is enough: a named key, or a value whose shape gives it away.
|
|
58
|
+
* @param name - environment variable or header name
|
|
59
|
+
* @param value - the value to judge
|
|
60
|
+
*/
|
|
61
|
+
export function isCredential(name: string, value: string): boolean {
|
|
62
|
+
return namesCredential(name) || looksLikeCredential(value)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The form a caller may see. A recognized label stays, so `Bearer …` still reads as a bearer token
|
|
67
|
+
* and `ghp_…` as a GitHub token; the body behind it keeps its first and last two characters, and
|
|
68
|
+
* below five characters none of it. `Bearer abcdefgh` becomes `Bearer ab*****gh`.
|
|
69
|
+
* @param value - the credential value
|
|
70
|
+
*/
|
|
71
|
+
export function maskCredential(value: string): string {
|
|
72
|
+
const label = CREDENTIAL_LABEL.exec(value)?.[0] ?? ''
|
|
73
|
+
const body = value.slice(label.length)
|
|
74
|
+
if (body.length === 0) return value
|
|
75
|
+
if (body.length < 5) return `${label}*****`
|
|
76
|
+
return `${label}${body.slice(0, 2)}*****${body.slice(-2)}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeName(name: string): string {
|
|
80
|
+
return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase().replace(/[^A-Z0-9]+/g, '_')
|
|
81
|
+
}
|