Skip to content

Commit 1429f74

Browse files
committed
feat: support global_env
1 parent 351a92b commit 1429f74

5 files changed

Lines changed: 172 additions & 6 deletions

File tree

lua/mcphub/config.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ local defaults = {
5656
auto_approve = false,
5757
auto_toggle_mcp_servers = true, -- Let LLMs start and stop MCP servers automatically
5858
use_bundled_binary = false, -- Whether to use bundled mcp-hub binary
59+
---@type table | fun(context: MCPHub.JobContext): table Global environment variables available to all MCP servers
60+
global_env = {}, -- Environment variables that will be available to all MCP servers
5961
---@type string?
6062
cmd = nil, -- will be set based on system if not provided
6163
---@type table?

lua/mcphub/hub.lua

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local Error = require("mcphub.utils.errors")
22
local Job = require("plenary.job")
33
local State = require("mcphub.state")
4+
local config = require("mcphub.config")
45
local config_manager = require("mcphub.utils.config_manager")
56
local constants = require("mcphub.utils.constants")
67
local curl = require("plenary.curl")
@@ -63,7 +64,7 @@ function MCPHub:new(opts)
6364
end
6465

6566
--- Resolve context (workspace vs global) for the current directory
66-
--- @return table|nil Context information or nil on error
67+
--- @return MCPHub.JobContext|nil Context information or nil on error
6768
function MCPHub:resolve_context()
6869
if not State.config.workspace.enabled then
6970
return self:_resolve_global_context()
@@ -73,7 +74,7 @@ function MCPHub:resolve_context()
7374
end
7475

7576
--- Resolve workspace-specific context
76-
--- @return table|nil Workspace context or nil to fall back to global
77+
--- @return MCPHub.JobContext|nil Workspace context or nil to fall back to global
7778
function MCPHub:_resolve_workspace_context()
7879
local workspace_utils = require("mcphub.utils.workspace")
7980
local current_dir = vim.fn.getcwd()
@@ -133,7 +134,7 @@ function MCPHub:_resolve_workspace_context()
133134
end
134135

135136
--- Resolve global context (original behavior)
136-
--- @return table Global context
137+
--- @return MCPHub.JobContext Global context
137138
function MCPHub:_resolve_global_context()
138139
return {
139140
port = self.setup_opts.port,
@@ -144,11 +145,61 @@ function MCPHub:_resolve_global_context()
144145
}
145146
end
146147

148+
--- Resolve global environment variables
149+
--- @param context MCPHub.JobContext Hub context (workspace info, port, etc.)
150+
--- @return table Resolved global environment variables
151+
function MCPHub:_resolve_global_env(context)
152+
local global_env_config = config.global_env
153+
local resolved_global_env = {}
154+
155+
-- Handle function type
156+
if type(global_env_config) == "function" then
157+
local success, result = pcall(global_env_config, context)
158+
if not success then
159+
vim.notify("global_env function failed: " .. result, vim.log.levels.WARN)
160+
return {}
161+
end
162+
if type(result) ~= "table" then
163+
vim.notify("global_env function must return a table", vim.log.levels.WARN)
164+
return {}
165+
end
166+
global_env_config = result
167+
elseif type(global_env_config) ~= "table" then
168+
if global_env_config ~= nil then
169+
vim.notify("global_env must be table or function", vim.log.levels.WARN)
170+
end
171+
return {}
172+
end
173+
174+
-- Process mixed array/hash format
175+
for key, value in pairs(global_env_config) do
176+
if type(key) == "number" then
177+
-- Array-style entry: just the env var name
178+
if type(value) == "string" then
179+
local env_value = os.getenv(value)
180+
if env_value then
181+
resolved_global_env[value] = env_value
182+
end
183+
end
184+
else
185+
-- Hash-style entry: key = value
186+
if type(value) == "string" then
187+
resolved_global_env[key] = value
188+
end
189+
end
190+
end
191+
192+
return resolved_global_env
193+
end
194+
147195
--- Start server with resolved context
148-
--- @param context table Context information from resolve_context
196+
--- @param context MCPHub.JobContext Context information from resolve_context
149197
function MCPHub:_start_server_with_context(context)
150198
self.is_owner = true
151199

200+
-- Resolve global environment variables
201+
local resolved_global_env = self:_resolve_global_env(context)
202+
152203
-- Build command args with config files
153204
local args = utils.clean_args({
154205
self.cmdArgs,
@@ -170,6 +221,23 @@ function MCPHub:_start_server_with_context(context)
170221
"--watch",
171222
})
172223

224+
-- Prepare job environment with global env
225+
local job_env = {}
226+
if next(resolved_global_env) then
227+
-- Serialize global env for mcp-hub
228+
local success, json_env = pcall(vim.fn.json_encode, resolved_global_env)
229+
if success then
230+
job_env.MCP_HUB_ENV = json_env
231+
log.debug("Passing global environment variables to mcp-hub: " .. vim.inspect({
232+
count = vim.tbl_count(resolved_global_env),
233+
keys = vim.tbl_keys(resolved_global_env),
234+
}))
235+
else
236+
vim.notify("Failed to serialize global_env: " .. json_env, vim.log.levels.WARN)
237+
end
238+
end
239+
job_env = vim.tbl_extend("force", vim.fn.environ(), job_env or {})
240+
173241
log.debug("Starting server with context" .. vim.inspect({
174242
port = context.port,
175243
cwd = context.cwd,
@@ -182,6 +250,7 @@ function MCPHub:_start_server_with_context(context)
182250
command = self.cmd,
183251
args = args,
184252
cwd = context.cwd, -- Set working directory
253+
env = job_env, -- Pass environment variables
185254
hide = true,
186255
on_stderr = vim.schedule_wrap(function(_, data)
187256
if data then

lua/mcphub/types.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,10 @@
9696
---@field timeout? number
9797
---@field resetTimeoutOnProgress? boolean
9898
---@field maxTotalTimeout? number
99+
100+
---@class MCPHub.JobContext
101+
---@field cwd string -- Current working directory for the job
102+
---@field port number -- Port to connect to the MCP server
103+
---@field config_files string[] -- List of configuration files used to start the Hub including the project config and global
104+
---@field is_workspace_mode boolean -- Whether the job is running in workspace mode
105+
---@field workspace_root string? -- Root directory of the workspace if in workspace mode

tests/state.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ return {
4343
---@type number?
4444
started_at = nil, -- When server was started
4545
---@type MCPServer[]
46-
servers = require("tests.stubs.servers"), -- Regular MCP servers
46+
servers = {}, -- Regular MCP servers
4747
---@type NativeServer[]
48-
native_servers = require("tests.stubs.native_servers"), -- Native MCP servers
48+
native_servers = {}, -- Native MCP servers
4949
},
5050

5151
-- Error management

tests/test_global_env.lua

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
-- Tests for global_env functionality
2+
local new_set = MiniTest.new_set
3+
local expect, eq = MiniTest.expect, MiniTest.expect.equality
4+
local helpers = require("tests.helpers")
5+
6+
local T = new_set({
7+
hooks = {
8+
pre_case = function()
9+
-- Clean up environment before each test
10+
vim.env.MCP_HUB_ENV = nil
11+
vim.env.TEST_VAR = "test_value"
12+
vim.env.DBUS_SESSION_BUS_ADDRESS = "/tmp/test_dbus"
13+
end,
14+
post_case = function()
15+
-- Clean up after each test
16+
vim.env.MCP_HUB_ENV = nil
17+
vim.env.TEST_VAR = nil
18+
vim.env.DBUS_SESSION_BUS_ADDRESS = nil
19+
end,
20+
},
21+
})
22+
23+
-- Global Env Resolution
24+
T["resolution"] = new_set()
25+
26+
T["resolution"]["resolve_table_format"] = function()
27+
local hub, state = helpers.setup_plugin({
28+
global_env = {
29+
"TEST_VAR",
30+
"DBUS_SESSION_BUS_ADDRESS",
31+
CUSTOM_VAR = "custom_value",
32+
},
33+
})
34+
35+
local context = {
36+
port = 37373,
37+
is_workspace_mode = false,
38+
config_files = { "/test/config.json" },
39+
cwd = "/test",
40+
}
41+
42+
local resolved = hub:_resolve_global_env(context)
43+
44+
-- Check resolved values
45+
eq(resolved.TEST_VAR, "test_value")
46+
eq(resolved.DBUS_SESSION_BUS_ADDRESS, "/tmp/test_dbus")
47+
eq(resolved.CUSTOM_VAR, "custom_value")
48+
end
49+
50+
T["resolution"]["resolve_function_format"] = function()
51+
local hub, state = helpers.setup_plugin({
52+
global_env = function(context)
53+
return {
54+
"TEST_VAR",
55+
PORT = tostring(context.port),
56+
IS_WORKSPACE = context.is_workspace_mode and "true" or "false",
57+
}
58+
end,
59+
})
60+
61+
local context = {
62+
port = 40123,
63+
is_workspace_mode = true,
64+
config_files = { "/workspace/config.json" },
65+
cwd = "/workspace",
66+
}
67+
68+
local resolved = hub:_resolve_global_env(context)
69+
70+
-- Check resolved values
71+
eq(resolved.TEST_VAR, "test_value")
72+
eq(resolved.PORT, "40123")
73+
eq(resolved.IS_WORKSPACE, "true")
74+
end
75+
76+
T["resolution"]["nil_global_env"] = function()
77+
local hub, _ = helpers.setup_plugin({
78+
global_env = nil,
79+
})
80+
81+
local context = { port = 37373 }
82+
local resolved = hub:_resolve_global_env(context)
83+
84+
-- Should return empty table without warnings
85+
eq(vim.deep_equal(resolved, {}), true)
86+
end
87+
88+
return T

0 commit comments

Comments
 (0)