Skip to content

Commit 84ccf3f

Browse files
feat: add injectVariables(), support magic variables for MCPs (workspaceFolder) (#4442)
* feat: add `injectVariables()`, support magic variables for MCPs * fix: fallback for `workspaceFolder` should just be an empty string Previously this is intended so that the CLI receives a correct empty path argument, but on a second thought, if the user have added the quotes themselves it might cause error. Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * chore: remove unused import * chore: better log format * chore: better describe the accepted config type and more extensive test --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
1 parent c481827 commit 84ccf3f

File tree

3 files changed

+105
-18
lines changed

3 files changed

+105
-18
lines changed

src/services/mcp/McpHub.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from "../../shared/mcp"
3232
import { fileExistsAtPath } from "../../utils/fs"
3333
import { arePathsEqual } from "../../utils/path"
34-
import { injectEnv } from "../../utils/config"
34+
import { injectVariables } from "../../utils/config"
3535

3636
export type McpConnection = {
3737
server: McpServer
@@ -579,8 +579,11 @@ export class McpHub {
579579

580580
let transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport
581581

582-
// Inject environment variables to the config
583-
const configInjected = (await injectEnv(config)) as typeof config
582+
// Inject variables to the config (environment, magic variables,...)
583+
const configInjected = (await injectVariables(config, {
584+
env: process.env,
585+
workspaceFolder: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "",
586+
})) as typeof config
584587

585588
if (configInjected.type === "stdio") {
586589
transport = new StdioClientTransport({

src/utils/__tests__/config.spec.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { vitest, describe, it, expect, beforeEach, afterAll } from "vitest"
2-
import { injectEnv } from "../config"
2+
import { injectEnv, injectVariables } from "../config"
3+
34

45
describe("injectEnv", () => {
56
const originalEnv = process.env
@@ -30,14 +31,44 @@ describe("injectEnv", () => {
3031
key: "${env:API_KEY}",
3132
url: "${env:ENDPOINT}",
3233
nested: {
33-
value: "Keep this ${env:API_KEY}",
34+
string: "Keep this ${env:API_KEY}",
35+
number: 123,
36+
boolean: true,
37+
stringArr: ["${env:API_KEY}", "${env:ENDPOINT}"],
38+
numberArr: [123, 456],
39+
booleanArr: [true, false],
40+
},
41+
deeply: {
42+
nested: {
43+
string: "Keep this ${env:API_KEY}",
44+
number: 123,
45+
boolean: true,
46+
stringArr: ["${env:API_KEY}", "${env:ENDPOINT}"],
47+
numberArr: [123, 456],
48+
booleanArr: [true, false],
49+
},
3450
},
3551
}
3652
const expectedObject = {
3753
key: "12345",
3854
url: "https://example.com",
3955
nested: {
40-
value: "Keep this 12345",
56+
string: "Keep this 12345",
57+
number: 123,
58+
boolean: true,
59+
stringArr: ["12345", "https://example.com"],
60+
numberArr: [123, 456],
61+
booleanArr: [true, false],
62+
},
63+
deeply: {
64+
nested: {
65+
string: "Keep this 12345",
66+
number: 123,
67+
boolean: true,
68+
stringArr: ["12345", "https://example.com"],
69+
numberArr: [123, 456],
70+
booleanArr: [true, false],
71+
},
4172
},
4273
}
4374
const result = await injectEnv(configObject)
@@ -52,7 +83,7 @@ describe("injectEnv", () => {
5283
const result = await injectEnv(configString, "NOT_FOUND")
5384
expect(result).toBe(expectedString)
5485
expect(consoleWarnSpy).toHaveBeenCalledWith(
55-
"[injectEnv] env variable MISSING_VAR referenced but not found in process.env",
86+
`[injectVariables] variable "MISSING_VAR" referenced but not found in "env"`,
5687
)
5788
consoleWarnSpy.mockRestore()
5889
})
@@ -64,7 +95,7 @@ describe("injectEnv", () => {
6495
const result = await injectEnv(configString)
6596
expect(result).toBe(expectedString)
6697
expect(consoleWarnSpy).toHaveBeenCalledWith(
67-
"[injectEnv] env variable ANOTHER_MISSING referenced but not found in process.env",
98+
`[injectVariables] variable "ANOTHER_MISSING" referenced but not found in "env"`,
6899
)
69100
consoleWarnSpy.mockRestore()
70101
})
@@ -99,3 +130,22 @@ describe("injectEnv", () => {
99130
expect(result).toEqual({})
100131
})
101132
})
133+
134+
describe("injectVariables", () => {
135+
it("should replace singular variable", async () => {
136+
const result = await injectVariables("Hello ${v}", { v: "Hola" })
137+
expect(result).toEqual("Hello Hola")
138+
})
139+
140+
it("should handle undefined singular variable input", async () => {
141+
const result = await injectVariables("Hello ${v}", { v: undefined })
142+
expect(result).toEqual("Hello ${v}")
143+
})
144+
145+
it("should handle empty string singular variable input", async () => {
146+
const result = await injectVariables("Hello ${v}", { v: "" })
147+
expect(result).toEqual("Hello ")
148+
})
149+
150+
// Variable maps are already tested by `injectEnv` tests above.
151+
})

src/utils/config.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,59 @@
1+
export type InjectableConfigType =
2+
| string
3+
| {
4+
[key: string]:
5+
| undefined
6+
| null
7+
| boolean
8+
| number
9+
| InjectableConfigType
10+
| Array<undefined | null | boolean | number | InjectableConfigType>
11+
}
12+
113
/**
214
* Deeply injects environment variables into a configuration object/string/json
315
*
416
* Uses VSCode env:name pattern: https://code.visualstudio.com/docs/reference/variables-reference#_environment-variables
517
*
618
* Does not mutate original object
719
*/
8-
export async function injectEnv<C extends string | Record<PropertyKey, any>>(config: C, notFoundValue: any = "") {
9-
// Use simple regex replace for now, will see if object traversal and recursion is needed here (e.g: for non-serializable objects)
20+
export async function injectEnv<C extends InjectableConfigType>(config: C, notFoundValue: any = "") {
21+
return injectVariables(config, { env: process.env }, notFoundValue)
22+
}
1023

24+
/**
25+
* Deeply injects variables into a configuration object/string/json
26+
*
27+
* Uses VSCode's variables reference pattern: https://code.visualstudio.com/docs/reference/variables-reference#_environment-variables
28+
*
29+
* Does not mutate original object
30+
*
31+
* There is a special handling for a nested (record-type) variables, where it is replaced by `propNotFoundValue` (if available) if the root key exists but the nested key does not.
32+
*
33+
* Matched keys that have `null` | `undefined` values are treated as not found.
34+
*/
35+
export async function injectVariables<C extends InjectableConfigType>(
36+
config: C,
37+
variables: Record<string, undefined | null | string | Record<string, undefined | null | string>>,
38+
propNotFoundValue?: any,
39+
) {
40+
// Use simple regex replace for now, will see if object traversal and recursion is needed here (e.g: for non-serializable objects)
1141
const isObject = typeof config === "object"
1242
let _config: string = isObject ? JSON.stringify(config) : config
1343

14-
_config = _config.replace(/\$\{env:([\w]+)\}/g, (_, name) => {
15-
// Check if null or undefined
16-
// intentionally using == to match null | undefined
17-
if (process.env[name] == null) {
18-
console.warn(`[injectEnv] env variable ${name} referenced but not found in process.env`)
19-
}
44+
// Intentionally using `== null` to match null | undefined
45+
for (const [key, value] of Object.entries(variables)) {
46+
if (value == null) continue
47+
48+
if (typeof value === "string") _config = _config.replace(new RegExp(`\\$\\{${key}\\}`, "g"), value)
49+
else
50+
_config = _config.replace(new RegExp(`\\$\\{${key}:([\\w]+)\\}`, "g"), (match, name) => {
51+
if (value[name] == null)
52+
console.warn(`[injectVariables] variable "${name}" referenced but not found in "${key}"`)
2053

21-
return process.env[name] ?? notFoundValue
22-
})
54+
return value[name] ?? propNotFoundValue ?? match
55+
})
56+
}
2357

2458
return (isObject ? JSON.parse(_config) : _config) as C extends string ? string : C
2559
}

0 commit comments

Comments
 (0)