@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 ADDED
@@ -0,0 +1,8 @@
1
+ ---
2
+ status: locked
3
+ updated: 2026-10-01
4
+ ---
5
+
6
+ # @mohou/mcp-client
7
+
8
+ Role: `provider`. External MCP sessions for `ctx.mcp`. Not the runtime provider. Product: [ctx.mcp](../../../docs/product/mcp-client/ctx-mcp.md).
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
+ }