@teampitch/mcpx 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ generateTypeDefinitions,
4
+ generateToolListing,
5
+ sanitizeName,
6
+ type Backend,
7
+ } from "./backends.js";
8
+
9
+ // Minimal mock backend factory
10
+ function makeMockBackend(name: string, tools: Backend["tools"]): [string, Backend] {
11
+ return [
12
+ name,
13
+ {
14
+ name,
15
+ client: {} as Backend["client"],
16
+ tools,
17
+ },
18
+ ];
19
+ }
20
+
21
+ describe("sanitizeName", () => {
22
+ test("leaves alphanumeric and underscores unchanged", () => {
23
+ expect(sanitizeName("my_tool_name")).toBe("my_tool_name");
24
+ expect(sanitizeName("tool123")).toBe("tool123");
25
+ });
26
+
27
+ test("replaces hyphens with underscores", () => {
28
+ expect(sanitizeName("my-tool")).toBe("my_tool");
29
+ });
30
+
31
+ test("replaces dots and slashes with underscores", () => {
32
+ expect(sanitizeName("some.tool/name")).toBe("some_tool_name");
33
+ });
34
+
35
+ test("replaces spaces with underscores", () => {
36
+ expect(sanitizeName("my tool")).toBe("my_tool");
37
+ });
38
+
39
+ test("handles already clean name", () => {
40
+ expect(sanitizeName("cleanName")).toBe("cleanName");
41
+ });
42
+
43
+ test("handles empty string", () => {
44
+ expect(sanitizeName("")).toBe("");
45
+ });
46
+ });
47
+
48
+ describe("generateTypeDefinitions", () => {
49
+ test("returns header comment for empty backends", () => {
50
+ const backends = new Map<string, Backend>();
51
+ const result = generateTypeDefinitions(backends);
52
+ expect(result).toContain("Available MCP tool functions");
53
+ });
54
+
55
+ test("generates declare function for each tool", () => {
56
+ const backends = new Map<string, Backend>([
57
+ makeMockBackend("grafana", [
58
+ {
59
+ name: "search_dashboards",
60
+ description: "Search dashboards",
61
+ inputSchema: {
62
+ properties: { query: { type: "string", description: "Search query" } },
63
+ required: ["query"],
64
+ },
65
+ },
66
+ ]),
67
+ ]);
68
+
69
+ const result = generateTypeDefinitions(backends);
70
+ expect(result).toContain("declare function grafana_search_dashboards");
71
+ expect(result).toContain("query: string");
72
+ expect(result).toContain("Promise<any>");
73
+ });
74
+
75
+ test("marks optional params without required marker", () => {
76
+ const backends = new Map<string, Backend>([
77
+ makeMockBackend("mybackend", [
78
+ {
79
+ name: "my_tool",
80
+ description: "A tool",
81
+ inputSchema: {
82
+ properties: {
83
+ required_param: { type: "string" },
84
+ optional_param: { type: "number" },
85
+ },
86
+ required: ["required_param"],
87
+ },
88
+ },
89
+ ]),
90
+ ]);
91
+
92
+ const result = generateTypeDefinitions(backends);
93
+ expect(result).toContain("required_param: string");
94
+ expect(result).toContain("optional_param?: number");
95
+ });
96
+
97
+ test("uses any[] for array type params", () => {
98
+ const backends = new Map<string, Backend>([
99
+ makeMockBackend("srv", [
100
+ {
101
+ name: "bulk_op",
102
+ description: "Bulk operation",
103
+ inputSchema: {
104
+ properties: { items: { type: "array" } },
105
+ required: ["items"],
106
+ },
107
+ },
108
+ ]),
109
+ ]);
110
+
111
+ const result = generateTypeDefinitions(backends);
112
+ expect(result).toContain("items: any[]");
113
+ });
114
+
115
+ test("generates entries for multiple backends", () => {
116
+ const backends = new Map<string, Backend>([
117
+ makeMockBackend("alpha", [{ name: "do_a", description: "Do A", inputSchema: {} }]),
118
+ makeMockBackend("beta", [{ name: "do_b", description: "Do B", inputSchema: {} }]),
119
+ ]);
120
+
121
+ const result = generateTypeDefinitions(backends);
122
+ expect(result).toContain("// === alpha ===");
123
+ expect(result).toContain("declare function alpha_do_a");
124
+ expect(result).toContain("// === beta ===");
125
+ expect(result).toContain("declare function beta_do_b");
126
+ });
127
+
128
+ test("sanitizes hyphens in tool names", () => {
129
+ const backends = new Map<string, Backend>([
130
+ makeMockBackend("srv", [
131
+ { name: "my-hyphenated-tool", description: "A tool", inputSchema: {} },
132
+ ]),
133
+ ]);
134
+
135
+ const result = generateTypeDefinitions(backends);
136
+ expect(result).toContain("declare function srv_my_hyphenated_tool");
137
+ });
138
+
139
+ test("includes description snippet (up to 80 chars)", () => {
140
+ const desc = "A very useful tool that does something important";
141
+ const backends = new Map<string, Backend>([
142
+ makeMockBackend("srv", [{ name: "tool", description: desc, inputSchema: {} }]),
143
+ ]);
144
+
145
+ const result = generateTypeDefinitions(backends);
146
+ expect(result).toContain(desc);
147
+ });
148
+ });
149
+
150
+ describe("generateToolListing", () => {
151
+ test("returns empty string for no backends", () => {
152
+ const backends = new Map<string, Backend>();
153
+ expect(generateToolListing(backends)).toBe("");
154
+ });
155
+
156
+ test("lists tool with name and description", () => {
157
+ const backends = new Map<string, Backend>([
158
+ makeMockBackend("grafana", [
159
+ {
160
+ name: "search_dashboards",
161
+ description: "Search dashboards by query",
162
+ inputSchema: {},
163
+ },
164
+ ]),
165
+ ]);
166
+
167
+ const result = generateToolListing(backends);
168
+ expect(result).toContain("grafana_search_dashboards");
169
+ expect(result).toContain("Search dashboards by query");
170
+ });
171
+
172
+ test("truncates description to 60 chars", () => {
173
+ const longDesc = "A".repeat(100);
174
+ const backends = new Map<string, Backend>([
175
+ makeMockBackend("srv", [{ name: "tool", description: longDesc, inputSchema: {} }]),
176
+ ]);
177
+
178
+ const result = generateToolListing(backends);
179
+ const line = result.split("\n")[0];
180
+ // description portion should be 60 chars
181
+ expect(line).toContain("A".repeat(60));
182
+ expect(line).not.toContain("A".repeat(61));
183
+ });
184
+
185
+ test("handles tool with no description", () => {
186
+ const backends = new Map<string, Backend>([
187
+ makeMockBackend("srv", [{ name: "no_desc_tool", inputSchema: {} }]),
188
+ ]);
189
+
190
+ const result = generateToolListing(backends);
191
+ expect(result).toContain("srv_no_desc_tool: ");
192
+ });
193
+
194
+ test("generates one line per tool", () => {
195
+ const backends = new Map<string, Backend>([
196
+ makeMockBackend("srv", [
197
+ { name: "tool_a", description: "Tool A", inputSchema: {} },
198
+ { name: "tool_b", description: "Tool B", inputSchema: {} },
199
+ ]),
200
+ ]);
201
+
202
+ const lines = generateToolListing(backends).split("\n");
203
+ expect(lines).toHaveLength(2);
204
+ });
205
+ });
@@ -0,0 +1,159 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+
5
+ import type { BackendConfig } from "./config.js";
6
+ import { createOpenApiBackend } from "./openapi.js";
7
+
8
+ export interface ToolInfo {
9
+ name: string;
10
+ description?: string;
11
+ inputSchema: Record<string, unknown>;
12
+ }
13
+
14
+ export interface Backend {
15
+ name: string;
16
+ client: Client;
17
+ tools: ToolInfo[];
18
+ }
19
+
20
+ /** Connect to a backend MCP server via stdio subprocess */
21
+ async function connectStdio(name: string, config: BackendConfig): Promise<Backend> {
22
+ if (!config.command) throw new Error(`Backend "${name}" missing command`);
23
+
24
+ const transport = new StdioClientTransport({
25
+ command: config.command,
26
+ args: config.args ?? [],
27
+ env: { ...process.env, ...config.env } as Record<string, string>,
28
+ });
29
+
30
+ const client = new Client({ name: `mcpx-${name}`, version: "0.1.0" });
31
+ await client.connect(transport);
32
+
33
+ const { tools } = await client.listTools();
34
+ const toolInfos: ToolInfo[] = tools.map((t) => ({
35
+ name: t.name,
36
+ description: t.description,
37
+ inputSchema: t.inputSchema as Record<string, unknown>,
38
+ }));
39
+
40
+ console.log(` ${name}: ${toolInfos.length} tools connected`);
41
+ return { name, client, tools: toolInfos };
42
+ }
43
+
44
+ /** Connect to a backend MCP server via HTTP (Streamable HTTP) */
45
+ async function connectHttp(name: string, config: BackendConfig): Promise<Backend> {
46
+ if (!config.url) throw new Error(`Backend "${name}" missing url`);
47
+
48
+ const transport = new StreamableHTTPClientTransport(new URL(config.url), {
49
+ requestInit: config.headers ? { headers: config.headers } : undefined,
50
+ });
51
+
52
+ const client = new Client({ name: `mcpx-${name}`, version: "0.1.0" });
53
+ await client.connect(transport);
54
+
55
+ const { tools } = await client.listTools();
56
+ const toolInfos: ToolInfo[] = tools.map((t) => ({
57
+ name: t.name,
58
+ description: t.description,
59
+ inputSchema: t.inputSchema as Record<string, unknown>,
60
+ }));
61
+
62
+ console.log(` ${name}: ${toolInfos.length} tools connected (http)`);
63
+ return { name, client, tools: toolInfos };
64
+ }
65
+
66
+ /** Connect to all configured backends */
67
+ export async function connectBackends(
68
+ configs: Record<string, BackendConfig>,
69
+ ): Promise<Map<string, Backend>> {
70
+ const backends = new Map<string, Backend>();
71
+
72
+ for (const [name, config] of Object.entries(configs)) {
73
+ try {
74
+ if (config.transport === "stdio") {
75
+ const backend = await connectStdio(name, config);
76
+ backends.set(name, backend);
77
+ } else if (config.transport === "http") {
78
+ const backend = await connectHttp(name, config);
79
+ backends.set(name, backend);
80
+ } else if (config.transport === "openapi") {
81
+ const backend = await createOpenApiBackend(name, config);
82
+ backends.set(name, backend);
83
+ }
84
+ } catch (err) {
85
+ console.error(` ${name}: failed to connect —`, (err as Error).message);
86
+ }
87
+ }
88
+
89
+ return backends;
90
+ }
91
+
92
+ /** Refresh tool lists from a single backend */
93
+ async function refreshBackendTools(backend: Backend): Promise<void> {
94
+ const { tools } = await backend.client.listTools();
95
+ backend.tools = tools.map((t) => ({
96
+ name: t.name,
97
+ description: t.description,
98
+ inputSchema: t.inputSchema as Record<string, unknown>,
99
+ }));
100
+ }
101
+
102
+ /** Refresh tool lists from all backends */
103
+ export async function refreshAllTools(backends: Map<string, Backend>): Promise<void> {
104
+ for (const [name, backend] of backends) {
105
+ try {
106
+ await refreshBackendTools(backend);
107
+ } catch (err) {
108
+ console.error(` ${name}: tool refresh failed —`, (err as Error).message);
109
+ }
110
+ }
111
+ }
112
+
113
+ /** Generate TypeScript type definitions from all backend tools for the LLM */
114
+ export function generateTypeDefinitions(backends: Map<string, Backend>): string {
115
+ const lines: string[] = [
116
+ "// Available MCP tool functions — call these in your execute code",
117
+ "// Each function returns a Promise<{ content: Array<{ type: string, text: string }> }>",
118
+ "",
119
+ ];
120
+
121
+ for (const [name, backend] of backends) {
122
+ lines.push(`// === ${name} ===`);
123
+ for (const tool of backend.tools) {
124
+ const params = tool.inputSchema?.properties
125
+ ? Object.entries(
126
+ tool.inputSchema.properties as Record<string, { type?: string; description?: string }>,
127
+ )
128
+ .map(([k, v]) => {
129
+ const required = (tool.inputSchema.required as string[] | undefined)?.includes(k);
130
+ return `${k}${required ? "" : "?"}: ${v.type === "array" ? "any[]" : (v.type ?? "any")}`;
131
+ })
132
+ .join(", ")
133
+ : "";
134
+ const desc = tool.description ? ` — ${tool.description.slice(0, 80)}` : "";
135
+ lines.push(
136
+ `declare function ${name}_${sanitizeName(tool.name)}(args: { ${params} }): Promise<any>;${desc}`,
137
+ );
138
+ }
139
+ lines.push("");
140
+ }
141
+
142
+ return lines.join("\n");
143
+ }
144
+
145
+ /** Generate a compact tool listing for the search tool description */
146
+ export function generateToolListing(backends: Map<string, Backend>): string {
147
+ const lines: string[] = [];
148
+ for (const [name, backend] of backends) {
149
+ for (const tool of backend.tools) {
150
+ const desc = tool.description?.slice(0, 60) ?? "";
151
+ lines.push(`${name}_${sanitizeName(tool.name)}: ${desc}`);
152
+ }
153
+ }
154
+ return lines.join("\n");
155
+ }
156
+
157
+ export function sanitizeName(name: string): string {
158
+ return name.replace(/[^a-zA-Z0-9_]/g, "_");
159
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { writeFileSync, unlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadConfig } from "./config.js";
6
+
7
+ describe("loadConfig", () => {
8
+ let tmpFile: string;
9
+
10
+ beforeEach(() => {
11
+ tmpFile = join(tmpdir(), `mcpx-test-${Date.now()}.json`);
12
+ });
13
+
14
+ afterEach(() => {
15
+ try {
16
+ unlinkSync(tmpFile);
17
+ } catch {
18
+ // ignore if already deleted
19
+ }
20
+ });
21
+
22
+ test("loads minimal config with defaults", () => {
23
+ writeFileSync(
24
+ tmpFile,
25
+ JSON.stringify({
26
+ backends: {},
27
+ }),
28
+ );
29
+
30
+ const config = loadConfig(tmpFile);
31
+ expect(config.port).toBe(3100);
32
+ expect(config.authToken).toBeUndefined();
33
+ expect(config.backends).toEqual({});
34
+ });
35
+
36
+ test("loads port and authToken", () => {
37
+ writeFileSync(
38
+ tmpFile,
39
+ JSON.stringify({
40
+ port: 4000,
41
+ authToken: "secret-token",
42
+ backends: {},
43
+ }),
44
+ );
45
+
46
+ const config = loadConfig(tmpFile);
47
+ expect(config.port).toBe(4000);
48
+ expect(config.authToken).toBe("secret-token");
49
+ });
50
+
51
+ test("interpolates ${VAR} from process.env in authToken", () => {
52
+ process.env.TEST_AUTH_TOKEN = "from-env-token";
53
+ writeFileSync(
54
+ tmpFile,
55
+ JSON.stringify({
56
+ authToken: "${TEST_AUTH_TOKEN}",
57
+ backends: {},
58
+ }),
59
+ );
60
+
61
+ const config = loadConfig(tmpFile);
62
+ expect(config.authToken).toBe("from-env-token");
63
+ delete process.env.TEST_AUTH_TOKEN;
64
+ });
65
+
66
+ test("interpolates ${VAR} in backend env values", () => {
67
+ process.env.MY_API_KEY = "key-from-env";
68
+ writeFileSync(
69
+ tmpFile,
70
+ JSON.stringify({
71
+ backends: {
72
+ myserver: {
73
+ transport: "stdio",
74
+ command: "echo",
75
+ env: {
76
+ API_KEY: "${MY_API_KEY}",
77
+ STATIC: "literal-value",
78
+ },
79
+ },
80
+ },
81
+ }),
82
+ );
83
+
84
+ const config = loadConfig(tmpFile);
85
+ expect(config.backends.myserver.env?.API_KEY).toBe("key-from-env");
86
+ expect(config.backends.myserver.env?.STATIC).toBe("literal-value");
87
+ delete process.env.MY_API_KEY;
88
+ });
89
+
90
+ test("replaces missing env vars with empty string", () => {
91
+ delete process.env.MISSING_VAR;
92
+ writeFileSync(
93
+ tmpFile,
94
+ JSON.stringify({
95
+ backends: {
96
+ srv: {
97
+ transport: "stdio",
98
+ command: "echo",
99
+ env: { KEY: "${MISSING_VAR}" },
100
+ },
101
+ },
102
+ }),
103
+ );
104
+
105
+ const config = loadConfig(tmpFile);
106
+ expect(config.backends.srv.env?.KEY).toBe("");
107
+ });
108
+
109
+ test("throws on missing file", () => {
110
+ expect(() => loadConfig("/tmp/does-not-exist-mcpx.json")).toThrow();
111
+ });
112
+
113
+ test("throws on invalid JSON", () => {
114
+ writeFileSync(tmpFile, "not valid json {{{");
115
+ expect(() => loadConfig(tmpFile)).toThrow();
116
+ });
117
+
118
+ test("loads empty backends", () => {
119
+ writeFileSync(tmpFile, JSON.stringify({ backends: {} }));
120
+ const config = loadConfig(tmpFile);
121
+ expect(Object.keys(config.backends)).toHaveLength(0);
122
+ });
123
+
124
+ test("loads multiple backends", () => {
125
+ writeFileSync(
126
+ tmpFile,
127
+ JSON.stringify({
128
+ backends: {
129
+ grafana: { transport: "stdio", command: "grafana-mcp" },
130
+ github: { transport: "stdio", command: "github-mcp", args: ["--token", "abc"] },
131
+ },
132
+ }),
133
+ );
134
+
135
+ const config = loadConfig(tmpFile);
136
+ expect(Object.keys(config.backends)).toHaveLength(2);
137
+ expect(config.backends.grafana.command).toBe("grafana-mcp");
138
+ expect(config.backends.github.args).toEqual(["--token", "abc"]);
139
+ });
140
+
141
+ test("falls back to MCPX_AUTH_TOKEN env if authToken not in config", () => {
142
+ process.env.MCPX_AUTH_TOKEN = "env-fallback-token";
143
+ writeFileSync(tmpFile, JSON.stringify({ backends: {} }));
144
+
145
+ const config = loadConfig(tmpFile);
146
+ expect(config.authToken).toBe("env-fallback-token");
147
+ delete process.env.MCPX_AUTH_TOKEN;
148
+ });
149
+ });
package/src/config.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export interface BackendConfig {
4
+ /** Transport: stdio spawns a subprocess, http connects to a remote MCP server, openapi proxies a REST API */
5
+ transport: "stdio" | "http" | "openapi";
6
+ /** For stdio: command to run */
7
+ command?: string;
8
+ /** For stdio: arguments */
9
+ args?: string[];
10
+ /** For http: MCP server URL. For openapi: spec URL. */
11
+ url?: string;
12
+ /** For openapi: spec URL (alternative to url) */
13
+ specUrl?: string;
14
+ /** For openapi: base URL for API calls (defaults to spec servers[0]) */
15
+ baseUrl?: string;
16
+ /** Environment variables for stdio subprocess — supports ${VAR} interpolation from process.env */
17
+ env?: Record<string, string>;
18
+ /** HTTP headers for http transport — supports ${VAR} interpolation */
19
+ headers?: Record<string, string>;
20
+ /** JWT roles allowed to access this backend */
21
+ allowedRoles?: string[];
22
+ /** JWT teams allowed to access this backend */
23
+ allowedTeams?: string[];
24
+ }
25
+
26
+ export interface McpxConfig {
27
+ /** Port to listen on */
28
+ port: number;
29
+ /** Bearer token for authentication (optional) */
30
+ authToken?: string;
31
+ /** Allow startup with 0 connected backends (default: false) */
32
+ failOpen?: boolean;
33
+ /** Interval in seconds to refresh tool lists from backends (0 = disabled) */
34
+ toolRefreshInterval?: number;
35
+ /** Auth configuration */
36
+ auth?: {
37
+ /** Simple bearer token */
38
+ bearer?: string;
39
+ /** JWT verification */
40
+ jwt?: {
41
+ /** HMAC symmetric secret */
42
+ secret?: string;
43
+ /** JWKS endpoint for asymmetric keys */
44
+ jwksUrl?: string;
45
+ /** Expected audience claim */
46
+ audience?: string;
47
+ /** Expected issuer claim */
48
+ issuer?: string;
49
+ };
50
+ /** MCP OAuth 2.0 server */
51
+ oauth?: {
52
+ /** Issuer URL (e.g. https://mcp.yourcompany.com) */
53
+ issuer: string;
54
+ /** Allowed clients with redirect URI patterns */
55
+ clients: Array<{ name: string; redirectUri: string }>;
56
+ /** Secret for signing access tokens */
57
+ tokenSecret: string;
58
+ /** Access token TTL in minutes */
59
+ tokenTtlMinutes: number;
60
+ };
61
+ };
62
+ /** Session TTL in minutes for HTTP mode (default: 30) */
63
+ sessionTtlMinutes?: number;
64
+ /** Backend MCP servers */
65
+ backends: Record<string, BackendConfig>;
66
+ }
67
+
68
+ /** Interpolate ${VAR} references from process.env */
69
+ function interpolate(value: string): string {
70
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "");
71
+ }
72
+
73
+ function interpolateRecord(record: Record<string, string>): Record<string, string> {
74
+ const result: Record<string, string> = {};
75
+ for (const [k, v] of Object.entries(record)) {
76
+ result[k] = interpolate(v);
77
+ }
78
+ return result;
79
+ }
80
+
81
+ export function loadConfig(path: string): McpxConfig {
82
+ const raw = readFileSync(path, "utf-8");
83
+
84
+ // Simple YAML-like parser for our config format
85
+ // For production, use a proper YAML parser — keeping deps minimal for now
86
+ const parsed = JSON.parse(raw);
87
+
88
+ // Normalize auth config (backward compat: authToken → auth.bearer)
89
+ const legacyAuthToken = parsed.authToken
90
+ ? interpolate(parsed.authToken)
91
+ : process.env.MCPX_AUTH_TOKEN;
92
+
93
+ const auth = parsed.auth
94
+ ? {
95
+ bearer: parsed.auth.bearer ? interpolate(parsed.auth.bearer) : undefined,
96
+ jwt: parsed.auth.jwt
97
+ ? {
98
+ secret: parsed.auth.jwt.secret ? interpolate(parsed.auth.jwt.secret) : undefined,
99
+ jwksUrl: parsed.auth.jwt.jwksUrl ? interpolate(parsed.auth.jwt.jwksUrl) : undefined,
100
+ audience: parsed.auth.jwt.audience,
101
+ issuer: parsed.auth.jwt.issuer,
102
+ }
103
+ : undefined,
104
+ oauth: parsed.auth?.oauth
105
+ ? {
106
+ issuer: parsed.auth.oauth.issuer,
107
+ clients: parsed.auth.oauth.clients ?? [],
108
+ tokenSecret: interpolate(parsed.auth.oauth.tokenSecret ?? ""),
109
+ tokenTtlMinutes: parsed.auth.oauth.tokenTtlMinutes ?? 60,
110
+ }
111
+ : undefined,
112
+ }
113
+ : undefined;
114
+
115
+ const config: McpxConfig = {
116
+ port: parsed.port ?? 3100,
117
+ authToken: legacyAuthToken,
118
+ auth,
119
+ failOpen: parsed.failOpen ?? false,
120
+ toolRefreshInterval: parsed.toolRefreshInterval ?? 0,
121
+ sessionTtlMinutes: parsed.sessionTtlMinutes ?? 30,
122
+ backends: {},
123
+ };
124
+
125
+ for (const [name, backend] of Object.entries(parsed.backends as Record<string, BackendConfig>)) {
126
+ config.backends[name] = {
127
+ ...backend,
128
+ env: backend.env ? interpolateRecord(backend.env) : undefined,
129
+ headers: backend.headers ? interpolateRecord(backend.headers) : undefined,
130
+ };
131
+ }
132
+
133
+ return config;
134
+ }