@acarmisc/backstage-plugin-litellm-backend 0.24.0 → 0.25.0
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.
- package/dist/index.cjs.js +119 -60
- package/dist/index.cjs.js.map +4 -4
- package/dist/plugin.js +1 -0
- package/dist/routes/bridge.js +24 -0
- package/dist/services/keyService.js +27 -1
- package/dist/services/mcpAccess.d.ts +20 -0
- package/dist/services/mcpAccess.js +28 -0
- package/package.json +2 -2
package/dist/plugin.js
CHANGED
package/dist/routes/bridge.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.registerBridgeRoutes = registerBridgeRoutes;
|
|
|
4
4
|
const provisioning_1 = require("../provisioning");
|
|
5
5
|
const bridge_1 = require("../bridge");
|
|
6
6
|
const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
7
|
+
const mcpAccess_1 = require("../services/mcpAccess");
|
|
7
8
|
const keyService_1 = require("../services/keyService");
|
|
8
9
|
const errors_1 = require("../errors");
|
|
9
10
|
const backstage_plugin_litellm_common_2 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
@@ -175,6 +176,29 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
|
|
|
175
176
|
handleBridgeError(error, res);
|
|
176
177
|
}
|
|
177
178
|
});
|
|
179
|
+
// MCP servers the caller's team may use, for CLI clients that let the user
|
|
180
|
+
// pick which to attach to a new key. team_id is required: unlike models
|
|
181
|
+
// there is no meaningful unscoped catalogue to hand out.
|
|
182
|
+
router.get('/bridge/mcp', async (req, res) => {
|
|
183
|
+
try {
|
|
184
|
+
const claims = await requireClaims(req);
|
|
185
|
+
const teamId = typeof req.query.team_id === 'string' ? req.query.team_id : '';
|
|
186
|
+
if (!teamId) {
|
|
187
|
+
res.status(400).json({ error: 'team_id is required' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const user = await (0, bridge_1.getOrProvisionUserFromClaims)(client, claims, provisioningEnabled, provisioningDefaults, logger, bridgeIdentity);
|
|
191
|
+
if (!(user.teams ?? []).includes(teamId)) {
|
|
192
|
+
res.status(403).json({ error: 'Access denied: team is not one of your teams', team_id: teamId });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const names = (0, mcpAccess_1.teamMcpServers)(await client.getTeamInfo(teamId));
|
|
196
|
+
res.json(names.map(server_name => ({ server_name })));
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
handleBridgeError(error, res);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
178
202
|
}
|
|
179
203
|
else if (bridgeCfg.enabled) {
|
|
180
204
|
logger.warn('litellm.bridge.enabled is true but no verifier could be built — bridge endpoints not mounted');
|
|
@@ -11,6 +11,7 @@ exports.KeyServiceError = void 0;
|
|
|
11
11
|
exports.createKeyForUser = createKeyForUser;
|
|
12
12
|
const provisioning_1 = require("../provisioning");
|
|
13
13
|
const modelAccess_1 = require("./modelAccess");
|
|
14
|
+
const mcpAccess_1 = require("./mcpAccess");
|
|
14
15
|
/** Error thrown during key creation — maps to HTTP responses. */
|
|
15
16
|
class KeyServiceError extends Error {
|
|
16
17
|
constructor(status, body, message) {
|
|
@@ -58,6 +59,9 @@ async function createKeyForUser(user, input, ctx) {
|
|
|
58
59
|
});
|
|
59
60
|
}
|
|
60
61
|
}
|
|
62
|
+
// The team is read at most once per mint, and only if a check needs it.
|
|
63
|
+
let teamPromise;
|
|
64
|
+
const getTeam = (teamId) => (teamPromise ?? (teamPromise = client.getTeamInfo(teamId)));
|
|
61
65
|
// ── Validate models are subset of allowed ────────────────────────────
|
|
62
66
|
if (input.models && input.models.length > 0) {
|
|
63
67
|
// When team_id is set, use team's models; otherwise use user's models.
|
|
@@ -67,7 +71,7 @@ async function createKeyForUser(user, input, ctx) {
|
|
|
67
71
|
// Fetch team info to get its allowed models.
|
|
68
72
|
// Fail closed: if the team can't be fetched the error propagates
|
|
69
73
|
// rather than silently skipping the model check.
|
|
70
|
-
const teamInfo = await
|
|
74
|
+
const teamInfo = await getTeam(input.team_id);
|
|
71
75
|
allowedModels = teamInfo?.models ?? [];
|
|
72
76
|
}
|
|
73
77
|
else {
|
|
@@ -106,6 +110,23 @@ async function createKeyForUser(user, input, ctx) {
|
|
|
106
110
|
...clientMetadata,
|
|
107
111
|
...serverMetadata,
|
|
108
112
|
};
|
|
113
|
+
// ── Validate MCP servers are a subset of the team's ──────────────────
|
|
114
|
+
// [] is "no MCP servers" and needs no team; any name needs a team to be
|
|
115
|
+
// checked against, so a client cannot grant itself servers it has no
|
|
116
|
+
// ceiling for.
|
|
117
|
+
const mcpServers = input.mcp_servers ? [...new Set(input.mcp_servers)] : undefined;
|
|
118
|
+
if (mcpServers && mcpServers.length > 0) {
|
|
119
|
+
if (!input.team_id) {
|
|
120
|
+
throw new KeyServiceError(400, { error: 'team_id is required when mcp_servers is set' });
|
|
121
|
+
}
|
|
122
|
+
const disallowed = (0, mcpAccess_1.findDisallowedMcpServers)(mcpServers, (0, mcpAccess_1.teamMcpServers)(await getTeam(input.team_id)));
|
|
123
|
+
if (disallowed.length > 0) {
|
|
124
|
+
throw new KeyServiceError(403, {
|
|
125
|
+
error: 'One or more requested MCP servers are not allowed',
|
|
126
|
+
disallowed_mcp_servers: disallowed,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
109
130
|
// ── Build upstream request EXPLICITLY from parsed fields ──────────────
|
|
110
131
|
// Never spread the raw body; only include fields we've explicitly validated.
|
|
111
132
|
const upstreamRequest = {
|
|
@@ -139,6 +160,11 @@ async function createKeyForUser(user, input, ctx) {
|
|
|
139
160
|
if (input.key_type !== undefined) {
|
|
140
161
|
upstreamRequest.key_type = input.key_type;
|
|
141
162
|
}
|
|
163
|
+
if (mcpServers) {
|
|
164
|
+
upstreamRequest.object_permission = {
|
|
165
|
+
mcp_servers: mcpServers.length > 0 ? mcpServers : [mcpAccess_1.NO_MCP_SERVERS],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
142
168
|
upstreamRequest.metadata = enrichedMetadata;
|
|
143
169
|
// ── Call upstream LiteLLM ───────────────────────────────────────────
|
|
144
170
|
const result = await client.generateKey(upstreamRequest);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** LiteLLM's documented value that denies every MCP server. */
|
|
2
|
+
export declare const NO_MCP_SERVERS = "no-mcp-servers";
|
|
3
|
+
/**
|
|
4
|
+
* MCP servers a team may bind to a key: exactly the team's own list, as stored
|
|
5
|
+
* (LiteLLM accepts a server's name or id interchangeably when enforcing). A team
|
|
6
|
+
* with no list gets nothing: "empty" means unrestricted to LiteLLM, but a key
|
|
7
|
+
* list is a grant, so an unrestricted team must not let a member name servers it
|
|
8
|
+
* was never given. Entries the mint schema would reject are dropped so the
|
|
9
|
+
* picker never offers an unselectable name.
|
|
10
|
+
*
|
|
11
|
+
* shortcut: team `mcp_access_groups` are not expanded into server names; add
|
|
12
|
+
* that when a team grants MCP access only through groups.
|
|
13
|
+
*/
|
|
14
|
+
export declare function teamMcpServers(team: {
|
|
15
|
+
object_permission?: {
|
|
16
|
+
mcp_servers?: string[];
|
|
17
|
+
};
|
|
18
|
+
} | undefined): string[];
|
|
19
|
+
/** Requested names that are not in `allowed`, de-duplicated. */
|
|
20
|
+
export declare function findDisallowedMcpServers(requested: string[], allowed: string[]): string[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NO_MCP_SERVERS = void 0;
|
|
4
|
+
exports.teamMcpServers = teamMcpServers;
|
|
5
|
+
exports.findDisallowedMcpServers = findDisallowedMcpServers;
|
|
6
|
+
const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
|
|
7
|
+
/** LiteLLM's documented value that denies every MCP server. */
|
|
8
|
+
exports.NO_MCP_SERVERS = 'no-mcp-servers';
|
|
9
|
+
/**
|
|
10
|
+
* MCP servers a team may bind to a key: exactly the team's own list, as stored
|
|
11
|
+
* (LiteLLM accepts a server's name or id interchangeably when enforcing). A team
|
|
12
|
+
* with no list gets nothing: "empty" means unrestricted to LiteLLM, but a key
|
|
13
|
+
* list is a grant, so an unrestricted team must not let a member name servers it
|
|
14
|
+
* was never given. Entries the mint schema would reject are dropped so the
|
|
15
|
+
* picker never offers an unselectable name.
|
|
16
|
+
*
|
|
17
|
+
* shortcut: team `mcp_access_groups` are not expanded into server names; add
|
|
18
|
+
* that when a team grants MCP access only through groups.
|
|
19
|
+
*/
|
|
20
|
+
function teamMcpServers(team) {
|
|
21
|
+
const listed = team?.object_permission?.mcp_servers ?? [];
|
|
22
|
+
return [...new Set(listed)].filter(n => backstage_plugin_litellm_common_1.MCP_SERVER_NAME_RE.test(n));
|
|
23
|
+
}
|
|
24
|
+
/** Requested names that are not in `allowed`, de-duplicated. */
|
|
25
|
+
function findDisallowedMcpServers(requested, allowed) {
|
|
26
|
+
const ok = new Set(allowed);
|
|
27
|
+
return [...new Set(requested)].filter(n => !ok.has(n));
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acarmisc/backstage-plugin-litellm-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "The Backstage backend plugin for LiteLLM governance",
|
|
5
5
|
"backstage": {
|
|
6
6
|
"role": "backend-plugin",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"prepack": "npm run build"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@acarmisc/backstage-plugin-litellm-common": "0.
|
|
38
|
+
"@acarmisc/backstage-plugin-litellm-common": "0.5.0",
|
|
39
39
|
"@backstage/backend-plugin-api": "^1.9.1",
|
|
40
40
|
"@backstage/catalog-client": "^1.9.0",
|
|
41
41
|
"@backstage/config": "^1.3.0",
|