@lightsage/mcp 0.4.14 → 0.4.16
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/README.md +2 -2
- package/api/mcp.js +21 -5
- package/bin/mcp-server.js +1250 -398
- package/bin/mcp-server.js.map +44 -40
- package/esm/funcs/evalRunsList.d.ts +1 -1
- package/esm/funcs/evalRunsList.js +1 -1
- package/esm/landing-page.js +1 -1
- package/esm/lib/config.d.ts +3 -3
- package/esm/lib/config.js +3 -3
- package/esm/mcp-server/mcp-server.js +1 -1
- package/esm/mcp-server/server.js +1 -1
- package/esm/mcp-server/tools/evalRunsList.js +1 -1
- package/esm/mcp-server/tools/evalRunsList.js.map +1 -1
- package/esm/models/createevaltemplaterequest.d.ts +2 -1
- package/esm/models/createevaltemplaterequest.d.ts.map +1 -1
- package/esm/models/createevaltemplaterequest.js +2 -1
- package/esm/models/createevaltemplaterequest.js.map +1 -1
- package/esm/models/evaltemplate.d.ts +1 -0
- package/esm/models/evaltemplate.d.ts.map +1 -1
- package/esm/models/evaltemplate.js +1 -0
- package/esm/models/evaltemplate.js.map +1 -1
- package/esm/models/graderdefinition.d.ts +14 -0
- package/esm/models/graderdefinition.d.ts.map +1 -1
- package/esm/models/graderdefinition.js +12 -0
- package/esm/models/graderdefinition.js.map +1 -1
- package/esm/models/updateevaltemplaterequest.d.ts +1 -0
- package/esm/models/updateevaltemplaterequest.d.ts.map +1 -1
- package/esm/models/updateevaltemplaterequest.js +1 -0
- package/esm/models/updateevaltemplaterequest.js.map +1 -1
- package/esm/models/v2publictask.d.ts +24 -0
- package/esm/models/v2publictask.d.ts.map +1 -1
- package/esm/models/v2publictask.js +24 -1
- package/esm/models/v2publictask.js.map +1 -1
- package/esm/tool-names.js +1 -1
- package/esm/tool-names.js.map +1 -1
- package/lib/oauth.js +182 -9
- package/lib/profile.js +79 -0
- package/package.json +7 -4
- package/src/funcs/evalRunsList.ts +1 -1
- package/src/landing-page.ts +1 -1
- package/src/lib/config.ts +3 -3
- package/src/mcp-server/mcp-server.ts +1 -1
- package/src/mcp-server/server.ts +1 -1
- package/src/mcp-server/tools/evalRunsList.ts +1 -1
- package/src/models/createevaltemplaterequest.ts +6 -2
- package/src/models/evaltemplate.ts +4 -0
- package/src/models/graderdefinition.ts +18 -0
- package/src/models/updateevaltemplaterequest.ts +2 -0
- package/src/models/v2publictask.ts +43 -1
- package/src/tool-names.ts +1 -1
- package/test/mcp-auth.test.js +203 -0
package/test/mcp-auth.test.js
CHANGED
|
@@ -6,11 +6,27 @@ process.env.MCP_OAUTH_ENABLED = "true";
|
|
|
6
6
|
|
|
7
7
|
const { default: handler } = await import("../api/mcp.js");
|
|
8
8
|
const {
|
|
9
|
+
exchangeFailureResponse,
|
|
10
|
+
exchangeForLightsageIdentity,
|
|
9
11
|
exchangeForLightsageKey,
|
|
12
|
+
KeyExchangeError,
|
|
10
13
|
MCP_SUPPORTED_SCOPES,
|
|
11
14
|
oauthAuthenticationOptions,
|
|
12
15
|
protectedResourceMetadata,
|
|
16
|
+
tokenAudienceAllowed,
|
|
13
17
|
} = await import("../lib/oauth.js");
|
|
18
|
+
const { profileToolResult, registerProfileTool } = await import(
|
|
19
|
+
"../lib/profile.js"
|
|
20
|
+
);
|
|
21
|
+
const { Client } = await import(
|
|
22
|
+
"@modelcontextprotocol/sdk/client/index.js"
|
|
23
|
+
);
|
|
24
|
+
const { InMemoryTransport } = await import(
|
|
25
|
+
"@modelcontextprotocol/sdk/inMemory.js"
|
|
26
|
+
);
|
|
27
|
+
const { McpServer } = await import(
|
|
28
|
+
"@modelcontextprotocol/sdk/server/mcp.js"
|
|
29
|
+
);
|
|
14
30
|
const { resolveMCPServerAPIKey } = await import(
|
|
15
31
|
"../esm/mcp-server/flags.js"
|
|
16
32
|
);
|
|
@@ -219,3 +235,190 @@ test("OAuth key exchange forwards the Clerk user and client IDs", async (t) => {
|
|
|
219
235
|
"ls_cli_test",
|
|
220
236
|
);
|
|
221
237
|
});
|
|
238
|
+
|
|
239
|
+
function unsignedJwt(payload) {
|
|
240
|
+
const encode = (value) =>
|
|
241
|
+
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
242
|
+
return `${encode({ alg: "RS256", typ: "at+jwt" })}.${encode(payload)}.sig`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
test("OAuth tokens bound to another resource are rejected", () => {
|
|
246
|
+
const req = {
|
|
247
|
+
headers: { host: "mcp.lightsage.com", "x-forwarded-proto": "https" },
|
|
248
|
+
};
|
|
249
|
+
const allowed = (payload) => tokenAudienceAllowed(unsignedJwt(payload), req);
|
|
250
|
+
|
|
251
|
+
// Clerk copies the client's `resource` parameter into `aud` unchanged, so
|
|
252
|
+
// these are the same server as different clients may name it.
|
|
253
|
+
for (const aud of [
|
|
254
|
+
"https://mcp.lightsage.com/mcp",
|
|
255
|
+
"https://mcp.lightsage.com/mcp/",
|
|
256
|
+
"https://mcp.lightsage.com",
|
|
257
|
+
"https://mcp.lightsage.com/",
|
|
258
|
+
"https://mcp.lightsage.com:443/mcp",
|
|
259
|
+
"https://mcp.lightsage.com/mcp?x=1",
|
|
260
|
+
"https://mcp.usesapient.com/mcp",
|
|
261
|
+
]) {
|
|
262
|
+
assert.equal(allowed({ aud }), true, aud);
|
|
263
|
+
}
|
|
264
|
+
assert.equal(allowed({ aud: ["https://mcp.usesapient.com/mcp/"] }), true);
|
|
265
|
+
assert.equal(allowed({ resource: "https://mcp.lightsage.com/mcp" }), true);
|
|
266
|
+
|
|
267
|
+
for (const aud of [
|
|
268
|
+
"https://other.example.com/mcp",
|
|
269
|
+
"https://other.example.com",
|
|
270
|
+
"https://mcp.lightsage.com.evil.example/mcp",
|
|
271
|
+
"https://mcp.lightsage.com/other",
|
|
272
|
+
"https://mcp.lightsage.com/mc",
|
|
273
|
+
"https://mcp.lightsage.com/mcp/other",
|
|
274
|
+
"http://mcp.lightsage.com/mcp",
|
|
275
|
+
]) {
|
|
276
|
+
assert.equal(allowed({ aud }), false, aud);
|
|
277
|
+
}
|
|
278
|
+
assert.equal(
|
|
279
|
+
allowed({
|
|
280
|
+
aud: ["https://other.example.com/mcp"],
|
|
281
|
+
resource: "https://evil.example.com",
|
|
282
|
+
}),
|
|
283
|
+
false,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Clerk does not bind every OAuth token to a resource.
|
|
287
|
+
assert.equal(allowed({ sub: "user_1" }), true);
|
|
288
|
+
assert.equal(tokenAudienceAllowed("oat_opaque_token", req), true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("OAuth key exchange returns the connected profile and typed refusals", async (t) => {
|
|
292
|
+
process.env.LIGHTSAGE_EXCHANGE_URL = "https://api.example.test/mcp/exchange";
|
|
293
|
+
process.env.MCP_EXCHANGE_SECRET = "exchange-secret";
|
|
294
|
+
const profile = { id: "prf_1", email: "a@example.com", nickname: "Acme" };
|
|
295
|
+
|
|
296
|
+
const fetchMock = t.mock.method(globalThis, "fetch", async () =>
|
|
297
|
+
Response.json({ api_key: "ls_cli_test", profile }));
|
|
298
|
+
assert.deepEqual(
|
|
299
|
+
await exchangeForLightsageIdentity("user_123", "client_123"),
|
|
300
|
+
{ apiKey: "ls_cli_test", profile },
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
fetchMock.mock.mockImplementation(async () =>
|
|
304
|
+
Response.json(
|
|
305
|
+
{ detail: "Organization selection is required" },
|
|
306
|
+
{
|
|
307
|
+
status: 409,
|
|
308
|
+
headers: { "x-mcp-exchange-error": "organization_selection_required" },
|
|
309
|
+
},
|
|
310
|
+
));
|
|
311
|
+
await assert.rejects(
|
|
312
|
+
exchangeForLightsageIdentity("user_123", "client_123"),
|
|
313
|
+
(error) =>
|
|
314
|
+
error instanceof KeyExchangeError
|
|
315
|
+
&& error.status === 409
|
|
316
|
+
&& error.code === "organization_selection_required",
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("exchange refusals become re-authorization or access-denied responses", () => {
|
|
321
|
+
const req = {
|
|
322
|
+
headers: { host: "mcp.lightsage.com", "x-forwarded-proto": "https" },
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
for (
|
|
326
|
+
const code of [
|
|
327
|
+
"organization_selection_required",
|
|
328
|
+
"not_an_organization_member",
|
|
329
|
+
]
|
|
330
|
+
) {
|
|
331
|
+
const response = exchangeFailureResponse(
|
|
332
|
+
req,
|
|
333
|
+
new KeyExchangeError("refused", { status: 409, code }),
|
|
334
|
+
);
|
|
335
|
+
assert.equal(response.status, 401);
|
|
336
|
+
assert.match(response.headers["WWW-Authenticate"], /resource_metadata="/);
|
|
337
|
+
assert.match(response.headers["WWW-Authenticate"], /error="invalid_token"/);
|
|
338
|
+
assert.match(response.headers["WWW-Authenticate"], /error_description="/);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const code of ["user_not_provisioned", "no_organization"]) {
|
|
342
|
+
const response = exchangeFailureResponse(
|
|
343
|
+
req,
|
|
344
|
+
new KeyExchangeError("refused", { status: 404, code }),
|
|
345
|
+
);
|
|
346
|
+
assert.equal(response.status, 403);
|
|
347
|
+
assert.equal(response.body.error, "access_denied");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
assert.deepEqual(
|
|
351
|
+
exchangeFailureResponse(req, new Error("network down")),
|
|
352
|
+
{ status: 502, headers: {}, body: { error: "key_exchange_failed" } },
|
|
353
|
+
);
|
|
354
|
+
// An unconfigured exchange endpoint also answers 404, without a code.
|
|
355
|
+
assert.equal(
|
|
356
|
+
exchangeFailureResponse(
|
|
357
|
+
req,
|
|
358
|
+
new KeyExchangeError("disabled", { status: 404 }),
|
|
359
|
+
).status,
|
|
360
|
+
502,
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("profile tool is declared for OpenAI hosts and returns the profile", async () => {
|
|
365
|
+
const server = new McpServer({ name: "profile-test", version: "1.0.0" });
|
|
366
|
+
registerProfileTool(server, {
|
|
367
|
+
id: "prf_8d7e4b19",
|
|
368
|
+
email: "alex@example.com",
|
|
369
|
+
nickname: "Acme",
|
|
370
|
+
});
|
|
371
|
+
const client = new Client({ name: "profile-client", version: "1.0.0" });
|
|
372
|
+
const [clientTransport, serverTransport] = InMemoryTransport
|
|
373
|
+
.createLinkedPair();
|
|
374
|
+
await Promise.all([
|
|
375
|
+
server.connect(serverTransport),
|
|
376
|
+
client.connect(clientTransport),
|
|
377
|
+
]);
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const { tools } = await client.listTools();
|
|
381
|
+
assert.equal(tools.length, 1);
|
|
382
|
+
const [tool] = tools;
|
|
383
|
+
assert.equal(tool.name, "get_profile");
|
|
384
|
+
assert.equal(tool._meta["openai/profile"], true);
|
|
385
|
+
assert.deepEqual(tool._meta.securitySchemes, [
|
|
386
|
+
{ type: "oauth2", scopes: [] },
|
|
387
|
+
]);
|
|
388
|
+
assert.equal(tool.annotations.readOnlyHint, true);
|
|
389
|
+
assert.equal(tool.annotations.destructiveHint, false);
|
|
390
|
+
assert.equal(tool.annotations.openWorldHint, false);
|
|
391
|
+
assert.deepEqual(tool.inputSchema.properties, {});
|
|
392
|
+
assert.equal(tool.inputSchema.additionalProperties, false);
|
|
393
|
+
assert.deepEqual(tool.outputSchema.required, ["id"]);
|
|
394
|
+
assert.equal(tool.outputSchema.additionalProperties, false);
|
|
395
|
+
assert.deepEqual(Object.keys(tool.outputSchema.properties), [
|
|
396
|
+
"id",
|
|
397
|
+
"email",
|
|
398
|
+
"nickname",
|
|
399
|
+
]);
|
|
400
|
+
|
|
401
|
+
const result = await client.callTool({ name: "get_profile", arguments: {} });
|
|
402
|
+
const expected = {
|
|
403
|
+
id: "prf_8d7e4b19",
|
|
404
|
+
email: "alex@example.com",
|
|
405
|
+
nickname: "Acme",
|
|
406
|
+
};
|
|
407
|
+
assert.equal(result.isError, false);
|
|
408
|
+
assert.deepEqual(result.structuredContent, expected);
|
|
409
|
+
assert.deepEqual(JSON.parse(result.content[0].text), expected);
|
|
410
|
+
} finally {
|
|
411
|
+
await client.close();
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("profile tool never invents an identity", () => {
|
|
416
|
+
assert.deepEqual(profileToolResult({ id: "prf_1", email: "" }).structuredContent, {
|
|
417
|
+
id: "prf_1",
|
|
418
|
+
});
|
|
419
|
+
for (const profile of [null, {}, { id: " " }, { id: 42 }]) {
|
|
420
|
+
const result = profileToolResult(profile);
|
|
421
|
+
assert.equal(result.isError, true);
|
|
422
|
+
assert.equal(result.structuredContent, undefined);
|
|
423
|
+
}
|
|
424
|
+
});
|