feat: expand secure runtime and workspace UX
This commit is contained in:
@@ -150,7 +150,7 @@ describe('CapabilityService', () => {
|
||||
name: 'Remote MCP',
|
||||
description: 'Remote test server',
|
||||
enabled: true,
|
||||
assignments: ['opencode'],
|
||||
assignments: ['model'],
|
||||
secret: { action: 'replace', value: 'secret-token-value' },
|
||||
transport: 'http',
|
||||
url: 'https://mcp.example.com/mcp'
|
||||
@@ -181,7 +181,7 @@ describe('CapabilityService', () => {
|
||||
name: 'Local MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['opencode'],
|
||||
assignments: ['model'],
|
||||
secret: { action: 'keep' },
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
@@ -202,11 +202,69 @@ describe('CapabilityService', () => {
|
||||
name: 'Unsafe remote',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['opencode'],
|
||||
assignments: ['model'],
|
||||
secret: { action: 'replace', value: 'secret-token-value' },
|
||||
transport: 'http',
|
||||
url: 'http://mcp.example.com/mcp'
|
||||
})
|
||||
).rejects.toThrow('只能通过 HTTPS')
|
||||
})
|
||||
|
||||
it('rejects MCP assignments to Agent Runtimes', async () => {
|
||||
const { service } = await createService()
|
||||
|
||||
await expect(
|
||||
service.saveMcpServer(undefined, {
|
||||
name: 'Agent MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['opencode'],
|
||||
secret: { action: 'keep' },
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
})
|
||||
).rejects.toThrow('只能分配给直连模型')
|
||||
})
|
||||
|
||||
it('migrates legacy OpenCode MCP assignments to the direct model', async () => {
|
||||
const { filePath, builtinRoot, importedRoot } = await createService()
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
skills: {},
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
|
||||
name: 'Legacy MCP',
|
||||
description: '',
|
||||
enabled: true,
|
||||
assignments: ['opencode'],
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
}
|
||||
]
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
const service = new CapabilityService(
|
||||
filePath,
|
||||
builtinRoot,
|
||||
importedRoot,
|
||||
cipher
|
||||
)
|
||||
|
||||
await expect(service.getSnapshot()).resolves.toMatchObject({
|
||||
mcpServers: [
|
||||
expect.objectContaining({ assignments: ['model'] })
|
||||
]
|
||||
})
|
||||
expect(await readFile(filePath, 'utf8')).toContain(
|
||||
'"assignments": [\n "model"'
|
||||
)
|
||||
await expect(service.getResolvedMcpServers('opencode')).resolves.toEqual([])
|
||||
await expect(service.getResolvedMcpServers('model')).resolves.toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,8 +248,9 @@ export class CapabilityService {
|
||||
if (this.state) {
|
||||
return this.state
|
||||
}
|
||||
let loaded: StoredCapabilities
|
||||
try {
|
||||
this.state = storedCapabilitiesSchema.parse(
|
||||
loaded = storedCapabilitiesSchema.parse(
|
||||
JSON.parse(await readFile(this.filePath, 'utf8'))
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -259,15 +260,33 @@ export class CapabilityService {
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
this.state = { version: 1, skills: {}, mcpServers: [] }
|
||||
loaded = { version: 1, skills: {}, mcpServers: [] }
|
||||
} else {
|
||||
await rename(
|
||||
this.filePath,
|
||||
`${this.filePath}.corrupt-${Date.now()}`
|
||||
).catch(() => undefined)
|
||||
this.state = { version: 1, skills: {}, mcpServers: [] }
|
||||
loaded = { version: 1, skills: {}, mcpServers: [] }
|
||||
}
|
||||
}
|
||||
const migrateMcpAssignments = loaded.mcpServers.some((server) =>
|
||||
server.assignments.includes('opencode')
|
||||
)
|
||||
const migrated = migrateMcpAssignments
|
||||
? {
|
||||
...loaded,
|
||||
mcpServers: loaded.mcpServers.map((server) => ({
|
||||
...server,
|
||||
assignments: server.assignments.includes('opencode')
|
||||
? (['model'] as CapabilityAssignments)
|
||||
: server.assignments
|
||||
}))
|
||||
}
|
||||
: loaded
|
||||
this.state = storedCapabilitiesSchema.parse(migrated)
|
||||
if (migrateMcpAssignments) {
|
||||
await this.persist(this.state)
|
||||
}
|
||||
return this.state
|
||||
}
|
||||
|
||||
@@ -455,10 +474,10 @@ export class CapabilityService {
|
||||
const value = mcpServerInputSchema.parse(input)
|
||||
if (
|
||||
value.assignments.some(
|
||||
(assignment) => assignment !== 'opencode'
|
||||
(assignment) => assignment !== 'model'
|
||||
)
|
||||
) {
|
||||
throw new Error('当前版本的 MCP Server 只能分配给 OpenCode')
|
||||
throw new Error('当前版本的 MCP Server 只能分配给直连模型')
|
||||
}
|
||||
const state = await this.load()
|
||||
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
|
||||
@@ -634,6 +653,9 @@ export class CapabilityService {
|
||||
async getResolvedMcpServers(
|
||||
target: RuntimeTarget
|
||||
): Promise<ResolvedMcpServer[]> {
|
||||
if (target !== 'model') {
|
||||
return []
|
||||
}
|
||||
const state = await this.load()
|
||||
const assigned = state.mcpServers.filter(
|
||||
(server) => server.enabled && server.assignments.includes(target)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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 { ResolvedMcpServer } from './capability-service'
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpTransport(
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -1,79 +1,10 @@
|
||||
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'
|
||||
import { createMcpTransport } from './mcp-client-transport'
|
||||
|
||||
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> {
|
||||
@@ -81,7 +12,7 @@ export async function testMcpServer(
|
||||
name: 'goodbuddy',
|
||||
version: '0.1.0'
|
||||
})
|
||||
const transport = createTransport(server)
|
||||
const transport = createMcpTransport(server)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort(new Error('MCP 连接测试超时'))
|
||||
|
||||
Reference in New Issue
Block a user