feat: add persistent desktop assistant workspace

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-31 22:33:03 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 698a15ad14
commit 6ef1795b81
101 changed files with 31866 additions and 1176 deletions
@@ -0,0 +1,212 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
CapabilityService,
type CapabilityCipher
} from './capability-service'
const temporaryDirectories: string[] = []
const cipher: CapabilityCipher = {
isAvailable: () => true,
encrypt: (value) => Buffer.from(`encrypted:${value}`),
decrypt: (value) => value.toString().replace(/^encrypted:/u, '')
}
async function writeSkill(
root: string,
id: string,
name: string
): Promise<void> {
const directory = join(root, id)
await mkdir(directory, { recursive: true })
await writeFile(
join(directory, 'SKILL.md'),
[
'---',
`id: ${id}`,
`name: ${name}`,
`description: ${name}的测试说明`,
'version: 1.0.0',
'tags:',
' - 测试',
'---',
'',
`# ${name}`,
'',
'仅用于离线测试。'
].join('\n'),
'utf8'
)
}
async function createService(): Promise<{
directory: string
filePath: string
builtinRoot: string
importedRoot: string
service: CapabilityService
}> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-capabilities-'))
temporaryDirectories.push(directory)
const filePath = join(directory, 'capabilities.json')
const builtinRoot = join(directory, 'builtin')
const importedRoot = join(directory, 'imported')
await writeSkill(builtinRoot, 'document-writing', '文档写作')
return {
directory,
filePath,
builtinRoot,
importedRoot,
service: new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher
)
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('CapabilityService', () => {
it('discovers built-in skills and persists enablement and assignments', async () => {
const { filePath, builtinRoot, importedRoot, service } =
await createService()
await expect(service.getSnapshot()).resolves.toMatchObject({
skills: [
{
id: 'document-writing',
source: 'builtin',
enabled: true,
assignments: ['model', 'opencode', 'continue']
}
]
})
await service.setSkillEnabled('document-writing', false)
await service.setSkillAssignments('document-writing', ['model'])
const reloaded = new CapabilityService(
filePath,
builtinRoot,
importedRoot,
cipher
)
await expect(reloaded.getSnapshot()).resolves.toMatchObject({
skills: [
{
id: 'document-writing',
enabled: false,
assignments: ['model']
}
]
})
await expect(
reloaded.getSkillInstructions('continue', 10_000)
).resolves.toBe('')
await reloaded.setSkillEnabled('document-writing', true)
await expect(
reloaded.getSkillInstructions('model', 10_000)
).resolves.toContain('仅用于离线测试')
})
it('imports and removes a managed SKILL.md package', async () => {
const { directory, service } = await createService()
const packageRoot = join(directory, 'source-skill')
await writeSkill(packageRoot, 'meeting-helper', '会议助手')
const source = join(packageRoot, 'meeting-helper')
await writeFile(join(source, 'template.txt'), 'template', 'utf8')
const imported = await service.importSkill(source)
expect(imported.skills).toContainEqual(
expect.objectContaining({
id: 'meeting-helper',
source: 'imported'
})
)
const removed = await service.removeSkill('meeting-helper')
expect(removed.skills).not.toContainEqual(
expect.objectContaining({ id: 'meeting-helper' })
)
await expect(
service.removeSkill('document-writing')
).rejects.toThrow('只能删除已导入')
})
it('encrypts remote MCP secrets and never returns them publicly', async () => {
const { filePath, service } = await createService()
const snapshot = await service.saveMcpServer(undefined, {
name: 'Remote MCP',
description: 'Remote test server',
enabled: true,
assignments: ['opencode'],
secret: { action: 'replace', value: 'secret-token-value' },
transport: 'http',
url: 'https://mcp.example.com/mcp'
})
const server = snapshot.mcpServers[0]
expect(server).toMatchObject({
name: 'Remote MCP',
transport: 'http',
secretConfigured: true
})
expect(JSON.stringify(snapshot)).not.toContain('secret-token-value')
expect(await readFile(filePath, 'utf8')).not.toContain(
'secret-token-value'
)
if (!server) {
throw new Error('Expected saved MCP server')
}
await expect(
service.getResolvedMcpServer(server.id)
).resolves.toMatchObject({
secret: 'secret-token-value'
})
})
it('stores stdio command and arguments as separate values', async () => {
const { service } = await createService()
const snapshot = await service.saveMcpServer(undefined, {
name: 'Local MCP',
description: '',
enabled: true,
assignments: ['opencode'],
secret: { action: 'keep' },
transport: 'stdio',
command: 'node',
args: ['server.js', '--safe']
})
expect(snapshot.mcpServers[0]).toMatchObject({
transport: 'stdio',
command: 'node',
args: ['server.js', '--safe']
})
})
it('never sends a bearer token over non-loopback HTTP', async () => {
const { service } = await createService()
await expect(
service.saveMcpServer(undefined, {
name: 'Unsafe remote',
description: '',
enabled: true,
assignments: ['opencode'],
secret: { action: 'replace', value: 'secret-token-value' },
transport: 'http',
url: 'http://mcp.example.com/mcp'
})
).rejects.toThrow('只能通过 HTTPS')
})
})
+645
View File
@@ -0,0 +1,645 @@
import { createHash, randomUUID } from 'node:crypto'
import {
lstat,
mkdir,
readdir,
readFile,
realpath,
rename,
rm,
stat,
writeFile
} from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { z } from 'zod'
import {
capabilityAssignmentsSchema,
mcpServerIdSchema,
mcpServerInputSchema,
mcpServerSummarySchema,
skillIdSchema,
skillSummarySchema,
type CapabilityAssignments,
type CapabilitySnapshot,
type McpServerInput,
type McpServerSummary,
type RuntimeTarget,
type SkillSummary
} from '../../shared/capability-contracts'
const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024
const MAX_SKILL_PACKAGE_BYTES = 10 * 1024 * 1024
const MAX_SKILL_PACKAGE_FILES = 128
const MAX_SKILL_DEPTH = 6
const skillMetadataSchema = z
.object({
id: skillIdSchema,
name: z.string().trim().min(1).max(80),
description: z.string().trim().min(1).max(500),
version: z.string().trim().min(1).max(32).optional(),
tags: z.array(z.string().trim().min(1).max(32)).max(12).default([])
})
.strict()
const skillStateSchema = z
.object({
enabled: z.boolean(),
assignments: capabilityAssignmentsSchema
})
.strict()
const encryptedSecretSchema = z
.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z.string()
})
.optional()
const storedMcpCommonShape = {
id: mcpServerIdSchema,
name: z.string(),
description: z.string(),
enabled: z.boolean(),
assignments: capabilityAssignmentsSchema,
credential: encryptedSecretSchema
}
const storedMcpServerSchema = z.discriminatedUnion('transport', [
z
.object({
...storedMcpCommonShape,
transport: z.literal('stdio'),
command: z.string(),
args: z.array(z.string())
})
.strict(),
z
.object({
...storedMcpCommonShape,
transport: z.literal('http'),
url: z.string()
})
.strict(),
z
.object({
...storedMcpCommonShape,
transport: z.literal('sse'),
url: z.string()
})
.strict()
])
const storedCapabilitiesSchema = z
.object({
version: z.literal(1),
skills: z.record(skillIdSchema, skillStateSchema),
mcpServers: z.array(storedMcpServerSchema).max(64)
})
.strict()
type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema>
type StoredMcpServer = z.infer<typeof storedMcpServerSchema>
const secretPayloadSchema = z
.object({
version: z.literal(1),
serverId: mcpServerIdSchema,
secret: z.string()
})
.strict()
export type CapabilityCipher = {
isAvailable: () => boolean
encrypt: (value: string) => Buffer
decrypt: (value: Buffer) => string
}
export type ResolvedMcpServer = McpServerSummary & {
secret?: string
}
function defaultSkillState(): z.infer<typeof skillStateSchema> {
return {
enabled: true,
assignments: ['model', 'opencode', 'continue']
}
}
async function readSkill(
directoryPath: string,
source: SkillSummary['source'],
expectedId = basename(directoryPath)
): Promise<Omit<SkillSummary, 'enabled' | 'assignments'>> {
const filePath = join(directoryPath, 'SKILL.md')
const file = await stat(filePath)
if (!file.isFile() || file.size > MAX_SKILL_FILE_BYTES) {
throw new Error(`${basename(directoryPath)} 的 SKILL.md 无效或过大`)
}
const content = await readFile(filePath, 'utf8')
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(content)
if (!match?.[1] || !match[2]?.trim()) {
throw new Error(`${basename(directoryPath)} 的 SKILL.md 格式无效`)
}
const metadata = skillMetadataSchema.parse(parseYaml(match[1]))
if (metadata.id !== expectedId) {
throw new Error(`Skill ID 必须与目录名一致:${metadata.id}`)
}
return skillSummarySchema
.omit({ enabled: true, assignments: true })
.parse({
...metadata,
source,
digest: createHash('sha256').update(content).digest('hex')
})
}
async function listSkills(
root: string,
source: SkillSummary['source']
): Promise<Array<Omit<SkillSummary, 'enabled' | 'assignments'>>> {
let entries
try {
entries = await readdir(root, { withFileTypes: true })
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return []
}
throw error
}
return Promise.all(
entries
.filter(
(entry) =>
entry.isDirectory() && !entry.name.startsWith('.')
)
.map((entry) => readSkill(join(root, entry.name), source))
)
}
async function copySkillPackage(
sourceRoot: string,
targetRoot: string
): Promise<void> {
let fileCount = 0
let totalBytes = 0
const copyDirectory = async (
source: string,
target: string,
depth: number
): Promise<void> => {
if (depth > MAX_SKILL_DEPTH) {
throw new Error('Skill 目录层级超过安全限制')
}
await mkdir(target, { recursive: true })
const entries = await readdir(source, { withFileTypes: true })
for (const entry of entries) {
const sourcePath = join(source, entry.name)
const targetPath = join(target, entry.name)
const details = await lstat(sourcePath)
if (details.isSymbolicLink()) {
throw new Error('Skill 包不能包含符号链接')
}
if (details.isDirectory()) {
await copyDirectory(sourcePath, targetPath, depth + 1)
continue
}
if (!details.isFile()) {
throw new Error('Skill 包只能包含普通文件和目录')
}
fileCount += 1
totalBytes += details.size
if (
fileCount > MAX_SKILL_PACKAGE_FILES ||
details.size > MAX_SKILL_FILE_BYTES ||
totalBytes > MAX_SKILL_PACKAGE_BYTES
) {
throw new Error('Skill 包大小或文件数量超过安全限制')
}
await writeFile(targetPath, await readFile(sourcePath), {
mode: 0o600
})
}
}
await copyDirectory(sourceRoot, targetRoot, 0)
}
export class CapabilityService {
private state?: StoredCapabilities
private updateQueue: Promise<void> = Promise.resolve()
constructor(
private readonly filePath: string,
private readonly builtinSkillsRoot: string,
private readonly importedSkillsRoot: string,
private readonly cipher: CapabilityCipher
) {}
private async load(): Promise<StoredCapabilities> {
if (this.state) {
return this.state
}
try {
this.state = storedCapabilitiesSchema.parse(
JSON.parse(await readFile(this.filePath, 'utf8'))
)
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
this.state = { version: 1, skills: {}, mcpServers: [] }
} else {
await rename(
this.filePath,
`${this.filePath}.corrupt-${Date.now()}`
).catch(() => undefined)
this.state = { version: 1, skills: {}, mcpServers: [] }
}
}
return this.state
}
private queue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.updateQueue.then(operation)
this.updateQueue = result.then(
() => undefined,
() => undefined
)
return result
}
private async persist(state: StoredCapabilities): Promise<void> {
const validated = storedCapabilitiesSchema.parse(state)
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
await writeFile(
temporaryPath,
`${JSON.stringify(validated, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 }
)
await rename(temporaryPath, this.filePath)
this.state = validated
}
private async getSkillCatalog(): Promise<
Array<Omit<SkillSummary, 'enabled' | 'assignments'>>
> {
const [builtins, imported] = await Promise.all([
listSkills(this.builtinSkillsRoot, 'builtin'),
listSkills(this.importedSkillsRoot, 'imported')
])
const builtinIds = new Set(builtins.map((skill) => skill.id))
const catalog = [
...builtins,
...imported.filter((skill) => !builtinIds.has(skill.id))
]
if (catalog.length > 256) {
throw new Error('Skill 数量超过 256 个安全限制')
}
return catalog
}
private toMcpSummary(server: StoredMcpServer): McpServerSummary {
const { credential, ...configuration } = server
return mcpServerSummarySchema.parse({
...configuration,
secretConfigured: Boolean(credential)
})
}
async getSnapshot(): Promise<CapabilitySnapshot> {
const [state, catalog] = await Promise.all([
this.load(),
this.getSkillCatalog()
])
return {
skills: catalog
.map((skill) => ({
...skill,
...(state.skills[skill.id] ?? defaultSkillState())
}))
.sort((left, right) =>
left.source === right.source
? left.name.localeCompare(right.name, 'zh-CN')
: left.source === 'builtin'
? -1
: 1
),
mcpServers: state.mcpServers.map((server) =>
this.toMcpSummary(server)
)
}
}
importSkill(sourcePath: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const canonicalSource = await realpath(sourcePath)
if (!(await stat(canonicalSource)).isDirectory()) {
throw new Error('所选 Skill 路径不是目录')
}
const skill = await readSkill(canonicalSource, 'imported')
const builtins = await listSkills(this.builtinSkillsRoot, 'builtin')
if (builtins.some((item) => item.id === skill.id)) {
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
}
const targetPath = join(this.importedSkillsRoot, skill.id)
if (
await stat(targetPath)
.then(() => true)
.catch(() => false)
) {
throw new Error('同名 Skill 已导入,请先删除后重试')
}
await mkdir(this.importedSkillsRoot, { recursive: true })
const temporaryPath = join(
this.importedSkillsRoot,
`.import-${randomUUID()}`
)
try {
await copySkillPackage(canonicalSource, temporaryPath)
await readSkill(temporaryPath, 'imported', skill.id)
await rename(temporaryPath, targetPath)
} catch (error) {
await rm(temporaryPath, { recursive: true, force: true })
throw error
}
const state = await this.load()
await this.persist({
...state,
skills: {
...state.skills,
[skill.id]: defaultSkillState()
}
})
return this.getSnapshot()
})
}
removeSkill(skillId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = skillIdSchema.parse(skillId)
const imported = await listSkills(this.importedSkillsRoot, 'imported')
if (!imported.some((skill) => skill.id === id)) {
throw new Error('只能删除已导入的 Skill')
}
await rm(join(this.importedSkillsRoot, id), {
recursive: true,
force: false
})
const state = await this.load()
const skills = { ...state.skills }
delete skills[id]
await this.persist({ ...state, skills })
return this.getSnapshot()
})
}
setSkillEnabled(
skillId: string,
enabled: boolean
): Promise<CapabilitySnapshot> {
return this.updateSkillState(skillId, { enabled })
}
setSkillAssignments(
skillId: string,
assignments: CapabilityAssignments
): Promise<CapabilitySnapshot> {
return this.updateSkillState(skillId, {
assignments: capabilityAssignmentsSchema.parse(assignments)
})
}
private updateSkillState(
skillId: string,
update: Partial<z.infer<typeof skillStateSchema>>
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = skillIdSchema.parse(skillId)
const catalog = await this.getSkillCatalog()
if (!catalog.some((skill) => skill.id === id)) {
throw new Error('Skill 不存在')
}
const state = await this.load()
await this.persist({
...state,
skills: {
...state.skills,
[id]: {
...(state.skills[id] ?? defaultSkillState()),
...update
}
}
})
return this.getSnapshot()
})
}
saveMcpServer(
serverId: string | undefined,
input: McpServerInput
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const value = mcpServerInputSchema.parse(input)
if (
value.assignments.some(
(assignment) => assignment !== 'opencode'
)
) {
throw new Error('当前版本的 MCP Server 只能分配给 OpenCode')
}
const state = await this.load()
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
const existing = state.mcpServers.find((server) => server.id === id)
if (serverId && !existing) {
throw new Error('MCP Server 不存在')
}
if (!existing && state.mcpServers.length >= 64) {
throw new Error('MCP Server 数量不能超过 64 个')
}
if (
existing &&
value.secret.action === 'keep' &&
existing.transport !== 'stdio' &&
value.transport !== 'stdio' &&
existing.url !== value.url &&
existing.credential
) {
throw new Error('MCP 地址已更改,请重新输入或清除访问令牌')
}
if (value.transport === 'stdio' && value.secret.action === 'replace') {
throw new Error('stdio MCP 不支持 Bearer Token')
}
let credential =
value.secret.action === 'keep' ? existing?.credential : undefined
if (value.secret.action === 'replace') {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,MCP 访问令牌未保存')
}
credential = {
formatVersion: 1 as const,
scheme: 'electron-safe-storage' as const,
ciphertextBase64: this.cipher
.encrypt(
JSON.stringify({
version: 1,
serverId: id,
secret: value.secret.value
})
)
.toString('base64')
}
}
if (
value.transport !== 'stdio' &&
credential &&
new URL(value.url).protocol !== 'https:' &&
!['localhost', '127.0.0.1', '[::1]'].includes(
new URL(value.url).hostname.toLowerCase()
)
) {
throw new Error(
'Bearer Token 只能通过 HTTPS 或本机回环地址发送'
)
}
const stored: StoredMcpServer =
value.transport === 'stdio'
? {
id,
name: value.name,
description: value.description,
enabled: value.enabled,
assignments: value.assignments,
transport: 'stdio',
command: value.command,
args: value.args
}
: {
id,
name: value.name,
description: value.description,
enabled: value.enabled,
assignments: value.assignments,
credential,
transport: value.transport,
url: new URL(value.url).toString()
}
const nextServers = existing
? state.mcpServers.map((server) =>
server.id === id ? stored : server
)
: [...state.mcpServers, stored]
await this.persist({ ...state, mcpServers: nextServers })
return this.getSnapshot()
})
}
removeMcpServer(serverId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = mcpServerIdSchema.parse(serverId)
const state = await this.load()
if (!state.mcpServers.some((server) => server.id === id)) {
throw new Error('MCP Server 不存在')
}
await this.persist({
...state,
mcpServers: state.mcpServers.filter((server) => server.id !== id)
})
return this.getSnapshot()
})
}
async getResolvedMcpServer(serverId: string): Promise<ResolvedMcpServer> {
const id = mcpServerIdSchema.parse(serverId)
const state = await this.load()
const server = state.mcpServers.find((item) => item.id === id)
if (!server) {
throw new Error('MCP Server 不存在')
}
let secret: string | undefined
if (server.credential) {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法读取 MCP 访问令牌')
}
try {
const payload = secretPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(server.credential.ciphertextBase64, 'base64')
)
)
)
if (payload.serverId === id) {
secret = payload.secret
}
} catch {
throw new Error('MCP 访问令牌无法解密,请重新配置')
}
}
return {
...this.toMcpSummary(server),
secret
}
}
async getSkillInstructions(
target: RuntimeTarget,
maximumCharacters: number
): Promise<string> {
const snapshot = await this.getSnapshot()
const sections: string[] = []
let length = 0
for (const skill of snapshot.skills) {
if (!skill.enabled || !skill.assignments.includes(target)) {
continue
}
const root =
skill.source === 'builtin'
? this.builtinSkillsRoot
: this.importedSkillsRoot
const content = await readFile(join(root, skill.id, 'SKILL.md'), 'utf8')
const body =
/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]+)$/u.exec(content)?.[1]?.trim() ??
''
const section = `## ${skill.name}\n${body}`
if (length + section.length > maximumCharacters) {
continue
}
sections.push(section)
length += section.length
}
return sections.length > 0
? [
'# GoodBuddy 已启用 Skills',
'以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。',
...sections
].join('\n\n')
: ''
}
async getResolvedMcpServers(
target: RuntimeTarget
): Promise<ResolvedMcpServer[]> {
const state = await this.load()
const assigned = state.mcpServers.filter(
(server) => server.enabled && server.assignments.includes(target)
)
return Promise.all(
assigned.map((server) => this.getResolvedMcpServer(server.id))
)
}
}
+144
View File
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedMcpServer } from './capability-service'
const mocks = vi.hoisted(() => {
const client = {
connect: vi.fn(),
listTools: vi.fn(),
getServerVersion: vi.fn(),
close: vi.fn()
}
return {
client,
Client: vi.fn(function Client() {
return client
}),
StdioClientTransport: vi.fn(function StdioClientTransport(
options: unknown
) {
return { kind: 'stdio', options }
}),
StreamableHTTPClientTransport: vi.fn(
function StreamableHTTPClientTransport(
url: URL,
options: unknown
) {
return { kind: 'http', url, options }
}
),
SSEClientTransport: vi.fn(function SSEClientTransport(
url: URL,
options: unknown
) {
return { kind: 'sse', url, options }
})
}
})
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: mocks.Client
}))
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: mocks.StdioClientTransport
}))
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
StreamableHTTPClientTransport: mocks.StreamableHTTPClientTransport
}))
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
SSEClientTransport: mocks.SSEClientTransport
}))
import { testMcpServer } from './mcp-tester'
const common = {
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
name: 'Test MCP',
description: '',
enabled: true,
assignments: ['model'] as Array<'model' | 'opencode' | 'continue'>,
secretConfigured: false
}
describe('testMcpServer', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.client.connect.mockResolvedValue(undefined)
mocks.client.listTools.mockResolvedValue({
tools: [
{
name: 'search',
description: 'Search documents'
}
]
})
mocks.client.getServerVersion.mockReturnValue({
name: 'test-server',
version: '1.0.0'
})
mocks.client.close.mockResolvedValue(undefined)
})
it('uses separated stdio command arguments and closes the client', async () => {
const result = await testMcpServer({
...common,
transport: 'stdio',
command: 'node',
args: ['server.js', '--safe']
} satisfies ResolvedMcpServer)
expect(mocks.StdioClientTransport).toHaveBeenCalledWith({
command: 'node',
args: ['server.js', '--safe'],
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
expect(mocks.client.connect).toHaveBeenCalledOnce()
expect(mocks.client.listTools).toHaveBeenCalledOnce()
expect(mocks.client.close).toHaveBeenCalledOnce()
expect(result).toEqual({
serverName: 'test-server',
serverVersion: '1.0.0',
toolCount: 1,
tools: [{ name: 'search', description: 'Search documents' }]
})
})
it('injects a bearer token only into the remote transport', async () => {
await testMcpServer({
...common,
transport: 'http',
url: 'https://mcp.example.com/mcp',
secretConfigured: true,
secret: 'test-secret'
} satisfies ResolvedMcpServer)
expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledOnce()
const [url, options] =
mocks.StreamableHTTPClientTransport.mock.calls[0] ?? []
expect(url).toEqual(new URL('https://mcp.example.com/mcp'))
expect(options).toMatchObject({
requestInit: {
headers: { Authorization: 'Bearer test-secret' }
},
reconnectionOptions: { maxRetries: 0 }
})
expect(options).toHaveProperty('fetch')
})
it('closes the client and returns a controlled error on failure', async () => {
mocks.client.connect.mockRejectedValue(
new Error('server included sensitive diagnostics')
)
await expect(
testMcpServer({
...common,
transport: 'sse',
url: 'https://mcp.example.com/sse'
} satisfies ResolvedMcpServer)
).rejects.toThrow(
'MCP Server 连接失败,请检查地址、命令和服务状态'
)
expect(mocks.client.close).toHaveBeenCalledOnce()
})
})
+124
View File
@@ -0,0 +1,124 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type {
FetchLike,
Transport
} from '@modelcontextprotocol/sdk/shared/transport.js'
import type { McpServerTestResult } from '../../shared/capability-contracts'
import type { ResolvedMcpServer } from './capability-service'
const MCP_TEST_TIMEOUT_MS = 12_000
function validateRemoteUrl(value: string): URL {
const url = new URL(value)
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/gu, '')
if (
hostname === '169.254.169.254' ||
hostname === 'metadata.google.internal' ||
hostname.endsWith('.internal.metadata')
) {
throw new Error('MCP 地址不能指向云平台元数据服务')
}
return url
}
function createRestrictedFetch(origin: string): FetchLike {
return async (input, init) => {
const url = new URL(String(input))
if (url.origin !== origin) {
throw new Error('MCP Server 尝试访问未授权的跨域地址')
}
return fetch(url, {
...init,
redirect: 'error'
})
}
}
function createTransport(server: ResolvedMcpServer): Transport {
if (server.transport === 'stdio') {
return new StdioClientTransport({
command: server.command,
args: server.args,
stderr: 'ignore',
maxBufferSize: 2 * 1024 * 1024
})
}
const url = validateRemoteUrl(server.url)
const requestInit: RequestInit | undefined = server.secret
? {
headers: {
Authorization: `Bearer ${server.secret}`
}
}
: undefined
const safeFetch = createRestrictedFetch(url.origin)
return server.transport === 'http'
? new StreamableHTTPClientTransport(url, {
fetch: safeFetch,
requestInit,
reconnectionOptions: {
initialReconnectionDelay: 500,
maxReconnectionDelay: 2_000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 0
}
})
: new SSEClientTransport(url, {
fetch: safeFetch,
requestInit
})
}
export async function testMcpServer(
server: ResolvedMcpServer
): Promise<McpServerTestResult> {
const client = new Client({
name: 'goodbuddy',
version: '0.1.0'
})
const transport = createTransport(server)
const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort(new Error('MCP 连接测试超时'))
}, MCP_TEST_TIMEOUT_MS)
try {
await client.connect(transport, {
timeout: MCP_TEST_TIMEOUT_MS,
signal: controller.signal
})
const result = await client.listTools(undefined, {
timeout: MCP_TEST_TIMEOUT_MS,
signal: controller.signal
})
const version = client.getServerVersion()
return {
serverName: version?.name.slice(0, 120),
serverVersion: version?.version.slice(0, 64),
toolCount: result.tools.length,
tools: result.tools.slice(0, 100).map((tool) => ({
name: tool.name.slice(0, 128),
description: tool.description?.slice(0, 500)
}))
}
} catch (error) {
if (controller.signal.aborted) {
throw new Error('MCP 连接测试超时', { cause: error })
}
throw new Error(
error instanceof Error &&
/unauthorized|401|403/iu.test(error.message)
? 'MCP Server 拒绝了访问,请检查 Bearer Token'
: 'MCP Server 连接失败,请检查地址、命令和服务状态',
{ cause: error }
)
} finally {
clearTimeout(timeout)
await client.close().catch(() => undefined)
}
}