Compare commits
2 commits
dev
...
restrict-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f62645677 | ||
|
|
d1adfdba16 |
3 changed files with 159 additions and 5 deletions
|
|
@ -117,6 +117,53 @@ type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"]
|
||||||
type ResourceTemplateInfo = Awaited<ReturnType<MCPClient["listResourceTemplates"]>>["resourceTemplates"][number]
|
type ResourceTemplateInfo = Awaited<ReturnType<MCPClient["listResourceTemplates"]>>["resourceTemplates"][number]
|
||||||
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
|
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
|
||||||
|
|
||||||
|
const LOCAL_MCP_INHERITED_ENV = [
|
||||||
|
"APPDATA",
|
||||||
|
"COMSPEC",
|
||||||
|
"HOME",
|
||||||
|
"HOMEDRIVE",
|
||||||
|
"HOMEPATH",
|
||||||
|
"LANG",
|
||||||
|
"LANGUAGE",
|
||||||
|
"LC_ADDRESS",
|
||||||
|
"LC_ALL",
|
||||||
|
"LC_COLLATE",
|
||||||
|
"LC_CTYPE",
|
||||||
|
"LC_IDENTIFICATION",
|
||||||
|
"LC_MEASUREMENT",
|
||||||
|
"LC_MESSAGES",
|
||||||
|
"LC_MONETARY",
|
||||||
|
"LC_NAME",
|
||||||
|
"LC_NUMERIC",
|
||||||
|
"LC_PAPER",
|
||||||
|
"LC_TELEPHONE",
|
||||||
|
"LC_TIME",
|
||||||
|
"LOCALAPPDATA",
|
||||||
|
"LOGNAME",
|
||||||
|
"PATH",
|
||||||
|
"PATHEXT",
|
||||||
|
"PROCESSOR_ARCHITECTURE",
|
||||||
|
"PROGRAMDATA",
|
||||||
|
"PROGRAMFILES",
|
||||||
|
"PROGRAMFILES(X86)",
|
||||||
|
"SHELL",
|
||||||
|
"SYSTEMDRIVE",
|
||||||
|
"SYSTEMROOT",
|
||||||
|
"TEMP",
|
||||||
|
"TERM",
|
||||||
|
"TMP",
|
||||||
|
"TMPDIR",
|
||||||
|
"USER",
|
||||||
|
"USERNAME",
|
||||||
|
"USERPROFILE",
|
||||||
|
"WINDIR",
|
||||||
|
"XDG_CACHE_HOME",
|
||||||
|
"XDG_CONFIG_HOME",
|
||||||
|
"XDG_DATA_HOME",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
|
"XDG_STATE_HOME",
|
||||||
|
] as const
|
||||||
|
|
||||||
function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
|
function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
|
||||||
return typeof entry === "object" && entry !== null && "type" in entry
|
return typeof entry === "object" && entry !== null && "type" in entry
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +172,27 @@ function remoteURL(value: string) {
|
||||||
if (URL.canParse(value)) return new URL(value)
|
if (URL.canParse(value)) return new URL(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localMcpEnvironment(command: string, environment?: Record<string, string>) {
|
||||||
|
const inherited = Object.fromEntries(
|
||||||
|
LOCAL_MCP_INHERITED_ENV.flatMap((key) => {
|
||||||
|
const value = process.env[key]
|
||||||
|
if (value === undefined || value.startsWith("()")) return []
|
||||||
|
return [[key, value] as const]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const defaults = {
|
||||||
|
...inherited,
|
||||||
|
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||||
|
}
|
||||||
|
if (process.platform !== "win32" || !environment) return { ...defaults, ...environment }
|
||||||
|
|
||||||
|
const configured = new Set(Object.keys(environment).map((key) => key.toUpperCase()))
|
||||||
|
return {
|
||||||
|
...Object.fromEntries(Object.entries(defaults).filter(([key]) => !configured.has(key.toUpperCase()))),
|
||||||
|
...environment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface CreateResult {
|
interface CreateResult {
|
||||||
mcpClient?: MCPClient
|
mcpClient?: MCPClient
|
||||||
status: Status
|
status: Status
|
||||||
|
|
@ -338,11 +406,7 @@ export const layer = Layer.effect(
|
||||||
command: cmd,
|
command: cmd,
|
||||||
args,
|
args,
|
||||||
cwd,
|
cwd,
|
||||||
env: {
|
env: localMcpEnvironment(cmd, mcp.environment),
|
||||||
...process.env,
|
|
||||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
|
||||||
...mcp.environment,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||||
|
|
|
||||||
22
packages/opencode/test/fixture/mcp-environment.js
Normal file
22
packages/opencode/test/fixture/mcp-environment.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import readline from "node:readline"
|
||||||
|
import { writeFile } from "node:fs/promises"
|
||||||
|
|
||||||
|
await writeFile(process.env.MCP_ENV_OUTPUT, JSON.stringify(process.env))
|
||||||
|
|
||||||
|
const lines = readline.createInterface({ input: process.stdin })
|
||||||
|
lines.on("close", () => process.exit(0))
|
||||||
|
lines.on("line", (line) => {
|
||||||
|
const request = JSON.parse(line)
|
||||||
|
if (request.method !== "initialize") return
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: request.id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: request.params?.protocolVersion,
|
||||||
|
capabilities: {},
|
||||||
|
serverInfo: { name: "environment-test", version: "1" },
|
||||||
|
},
|
||||||
|
})}\n`,
|
||||||
|
)
|
||||||
|
})
|
||||||
68
packages/opencode/test/mcp/environment.test.ts
Normal file
68
packages/opencode/test/mcp/environment.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import path from "node:path"
|
||||||
|
import { expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { MCP } from "../../src/mcp/index"
|
||||||
|
import { TestInstance } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(MCP.defaultLayer)
|
||||||
|
const inherited = [
|
||||||
|
"APPDATA",
|
||||||
|
"HOME",
|
||||||
|
"LANG",
|
||||||
|
"LOCALAPPDATA",
|
||||||
|
"PATH",
|
||||||
|
"PATHEXT",
|
||||||
|
"SYSTEMROOT",
|
||||||
|
"TEMP",
|
||||||
|
"TMPDIR",
|
||||||
|
"USERPROFILE",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
it.instance(
|
||||||
|
"local subprocess receives only baseline and configured environment",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const previous = process.env.OPENCODE_MCP_PARENT_SECRET
|
||||||
|
process.env.OPENCODE_MCP_PARENT_SECRET = "parent-secret"
|
||||||
|
|
||||||
|
yield* MCP.Service.use((mcp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const output = path.join(test.directory, "environment.json")
|
||||||
|
const result = yield* mcp.add("environment", {
|
||||||
|
type: "local",
|
||||||
|
command: [process.execPath, path.join(import.meta.dir, "../fixture/mcp-environment.js")],
|
||||||
|
environment: {
|
||||||
|
MCP_ENV_OUTPUT: output,
|
||||||
|
MCP_EXPLICIT_TOKEN: "configured-token",
|
||||||
|
...(process.platform === "win32" ? { Path: path.dirname(process.execPath) } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!("environment" in result.status)) throw new Error("Expected MCP status map")
|
||||||
|
expect(result.status.environment).toEqual({ status: "connected" })
|
||||||
|
const env = (yield* Effect.promise(() => Bun.file(output).json())) as Record<string, string>
|
||||||
|
expect(env.OPENCODE_MCP_PARENT_SECRET).toBeUndefined()
|
||||||
|
expect(env.MCP_EXPLICIT_TOKEN).toBe("configured-token")
|
||||||
|
inherited.forEach((key) => {
|
||||||
|
if (process.platform === "win32" && key === "PATH") return
|
||||||
|
if (process.env[key] !== undefined) expect(env[key]).toBe(process.env[key])
|
||||||
|
})
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
expect(Object.entries(env).find(([key]) => key.toUpperCase() === "PATH")?.[1]).toBe(
|
||||||
|
path.dirname(process.execPath),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}).pipe(Effect.ensuring(mcp.disconnect("environment").pipe(Effect.ignore))),
|
||||||
|
).pipe(
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (previous === undefined) delete process.env.OPENCODE_MCP_PARENT_SECRET
|
||||||
|
else process.env.OPENCODE_MCP_PARENT_SECRET = previous
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{ config: { mcp: {} } },
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue