@langwatch/mcp-server 0.10.1 → 1.0.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/.release-please-shim +1 -1
- package/CHANGELOG.md +54 -0
- package/README.md +9 -17
- package/dist/{archive-project-KAZFHR4A.js → archive-project-MK3673A7.js} +4 -3
- package/dist/{archive-scenario-6M2IB7Y2.js → archive-scenario-KATSXRIN.js} +4 -3
- package/dist/{archive-suite-NXZ3YDS3.js → archive-suite-C3KX6IIZ.js} +4 -3
- package/dist/{assign-prompt-tag-WPUJO55U.js → assign-prompt-tag-E3C5W6PI.js} +3 -2
- package/dist/chunk-37SMPMQS.js +12 -0
- package/dist/{chunk-CAY57CKF.js → chunk-5UWY24RP.js} +77 -4
- package/dist/chunk-BJ7B4R26.js +42 -0
- package/dist/chunk-CE6QMZOP.js +432 -0
- package/dist/{chunk-AGGE6NPR.js → chunk-DAIQKS27.js} +2 -2
- package/dist/{chunk-GWEW5XON.js → chunk-EU22L52K.js} +2 -2
- package/dist/{chunk-ITN2QGHF.js → chunk-J45SVWPR.js} +2 -2
- package/dist/{chunk-LTEJDL4V.js → chunk-O3LWVN67.js} +2 -2
- package/dist/{chunk-RDUXCCFR.js → chunk-RYRB2YVJ.js} +2 -2
- package/dist/{chunk-XRQNXN2Z.js → chunk-T7WJVS2N.js} +2 -2
- package/dist/{chunk-U2QIW2SW.js → chunk-UXXA4D6K.js} +2 -2
- package/dist/{chunk-HRFQAFPR.js → chunk-VYPSKRPN.js} +220 -133
- package/dist/{chunk-UKRCZJJC.js → chunk-WDH5P5RF.js} +2 -2
- package/dist/{chunk-COCNCXVJ.js → chunk-WWMDHZZV.js} +2 -2
- package/dist/{chunk-B6UV3JVI.js → chunk-XY4STOBE.js} +2 -2
- package/dist/{chunk-ESGZVPPC.js → chunk-Y3HDZ6OW.js} +2 -2
- package/dist/config.js +1 -0
- package/dist/{create-agent-BI7UTJ6A.js → create-agent-7W57J7MT.js} +4 -3
- package/dist/{create-annotation-SNM2J6S4.js → create-annotation-W2XELBCQ.js} +4 -3
- package/dist/{create-api-key-23MXYC2L.js → create-api-key-MKRXKFJM.js} +4 -3
- package/dist/{create-dashboard-CPOOOIKK.js → create-dashboard-QGL7IODC.js} +4 -3
- package/dist/{create-dataset-BVXPVVY2.js → create-dataset-3PH7A4HG.js} +4 -3
- package/dist/{create-dataset-records-S7VFIIMR.js → create-dataset-records-4DZS3VRL.js} +4 -3
- package/dist/{create-evaluator-WJAAQOUE.js → create-evaluator-YWH6NC4M.js} +4 -3
- package/dist/create-mcp-server.js +3 -2
- package/dist/{create-project-BF7LMZH5.js → create-project-BOBQ32C5.js} +4 -3
- package/dist/{create-prompt-LZ3QHSU4.js → create-prompt-FLCFGITE.js} +3 -2
- package/dist/{create-prompt-tag-IF4J24QM.js → create-prompt-tag-EMPNIKEA.js} +3 -2
- package/dist/{create-scenario-BK4E5DEU.js → create-scenario-BSBIHUL6.js} +4 -3
- package/dist/{create-suite-HEQPNLND.js → create-suite-TWF6BBR4.js} +4 -3
- package/dist/{delete-agent-Y4T2G5PT.js → delete-agent-7MSYNY5E.js} +4 -3
- package/dist/{delete-annotation-KYDMQTZG.js → delete-annotation-ZT5IGN24.js} +4 -3
- package/dist/{delete-dashboard-3RW647WJ.js → delete-dashboard-BTIY5S3L.js} +4 -3
- package/dist/{delete-dataset-SZAHFHHS.js → delete-dataset-U45HQHPR.js} +4 -3
- package/dist/{delete-dataset-records-ISHKGEBH.js → delete-dataset-records-R43IMAWB.js} +4 -3
- package/dist/{delete-evaluator-VKIG5I3S.js → delete-evaluator-U6JMQ6C7.js} +4 -3
- package/dist/{delete-prompt-tag-VYYL4GTF.js → delete-prompt-tag-4EV4TDHB.js} +3 -2
- package/dist/{delete-workflow-SHNPYSWQ.js → delete-workflow-5QYALXEL.js} +4 -3
- package/dist/discover-evaluator-schema-MRGHVNTT.js +2175 -0
- package/dist/{discover-scenario-schema-MEEEVND7.js → discover-scenario-schema-UPBTCWPG.js} +3 -1
- package/dist/{discover-schema-3T52ORPB.js → discover-schema-ICGXPHUW.js} +3 -1
- package/dist/{get-agent-IZSP7AR5.js → get-agent-7QJVPH72.js} +4 -3
- package/dist/{get-analytics-EDFAYYIQ.js → get-analytics-PVERWOA5.js} +3 -2
- package/dist/{get-dashboard-WSAO6IQ4.js → get-dashboard-ZZPC3WMD.js} +4 -3
- package/dist/{get-dataset-BEVNXQNF.js → get-dataset-25TB2LQH.js} +4 -3
- package/dist/{get-evaluator-HCVXNPZR.js → get-evaluator-3FTRQKEC.js} +4 -3
- package/dist/{get-project-H4V3GYRQ.js → get-project-5OAXYC57.js} +4 -3
- package/dist/{get-prompt-EWRJVVGB.js → get-prompt-I753LZKZ.js} +3 -2
- package/dist/{get-scenario-24YUJS5W.js → get-scenario-DSSTR6FI.js} +4 -3
- package/dist/{get-simulation-run-UX2W6F6I.js → get-simulation-run-RQPMS2MC.js} +4 -3
- package/dist/{get-suite-B65ZKZJA.js → get-suite-GTOCY5PL.js} +4 -3
- package/dist/{get-trace-ZR7MIITH.js → get-trace-O5VT3NAC.js} +7 -9
- package/dist/{get-workflow-YROHUQJW.js → get-workflow-3EZDOEIF.js} +4 -3
- package/dist/http-server-2ACJ3CXW.js +24366 -0
- package/dist/index.js +20935 -1751
- package/dist/{langwatch-api-annotations-OPEXNTPF.js → langwatch-api-annotations-E65MI3WZ.js} +4 -3
- package/dist/{langwatch-api-dashboards-NPQM6QJI.js → langwatch-api-dashboards-QCDECRCY.js} +4 -3
- package/dist/{langwatch-api-monitors-3L25OOOS.js → langwatch-api-monitors-UIO2KTDV.js} +3 -2
- package/dist/{langwatch-api-secrets-ZFURUA62.js → langwatch-api-secrets-4T2EKSJ7.js} +3 -2
- package/dist/{langwatch-api-triggers-XGXXT7DD.js → langwatch-api-triggers-MPOEKPUY.js} +3 -2
- package/dist/{langwatch-api-workflows-CXCTTCXT.js → langwatch-api-workflows-5HRZ7HR4.js} +4 -3
- package/dist/{list-agents-6ZNOV7FK.js → list-agents-TIJTP566.js} +4 -3
- package/dist/{list-annotations-6P3IWILH.js → list-annotations-CGX5BT7E.js} +4 -3
- package/dist/{list-api-keys-JJI62KD2.js → list-api-keys-JLWJPFRX.js} +4 -3
- package/dist/{list-dashboards-G2WJOXWB.js → list-dashboards-RNIWX3TX.js} +4 -3
- package/dist/{list-dataset-records-GWWVOCII.js → list-dataset-records-B5MHZR66.js} +4 -3
- package/dist/{list-datasets-HBTH33X4.js → list-datasets-BVCAMQCP.js} +4 -3
- package/dist/{list-evaluators-K5OGB6TX.js → list-evaluators-65FBTL2U.js} +4 -3
- package/dist/{list-model-providers-JMOGZLSO.js → list-model-providers-GRK3I3VV.js} +4 -3
- package/dist/{list-projects-OXJC2NW5.js → list-projects-JLMBFMW3.js} +4 -3
- package/dist/{list-prompt-tags-J2HPHVAZ.js → list-prompt-tags-UHNWDGQI.js} +3 -2
- package/dist/{list-prompts-C7GR72XL.js → list-prompts-7254B3U7.js} +3 -2
- package/dist/{list-scenarios-JEPGIIDO.js → list-scenarios-46YNICKM.js} +4 -3
- package/dist/{list-simulation-runs-DRSAGPEC.js → list-simulation-runs-LGNEFNPN.js} +4 -3
- package/dist/{list-suites-POTZ3ZT6.js → list-suites-5VK4HXEK.js} +4 -3
- package/dist/{list-workflows-QQZQIOHB.js → list-workflows-MLQDM6CP.js} +4 -3
- package/dist/{rename-prompt-tag-4AAV2BS4.js → rename-prompt-tag-RHQMNEQL.js} +3 -2
- package/dist/report-issue-DXJ6TPTC.js +412 -0
- package/dist/{revoke-api-key-TONB4ADQ.js → revoke-api-key-3Y35EMBT.js} +4 -3
- package/dist/{run-agent-7QE3YEH7.js → run-agent-RBSIFKRT.js} +4 -3
- package/dist/{run-suite-Q66OSPO4.js → run-suite-K3DNQ3SE.js} +4 -3
- package/dist/{run-workflow-RWX2D752.js → run-workflow-5LVBZYLS.js} +4 -3
- package/dist/{search-traces-M5E5VB4X.js → search-traces-U4HLOCZ2.js} +9 -2
- package/dist/{set-model-provider-XENT5D2P.js → set-model-provider-V3M5CDIY.js} +4 -3
- package/dist/{update-agent-GML25EE3.js → update-agent-47RZIHS7.js} +4 -3
- package/dist/{update-dataset-R4MM6NNY.js → update-dataset-G6QJJGS5.js} +4 -3
- package/dist/{update-dataset-record-FN4BL5P4.js → update-dataset-record-RMWLSWCS.js} +4 -3
- package/dist/{update-evaluator-LH2RDDB5.js → update-evaluator-SFRRI5Q3.js} +4 -3
- package/dist/{update-project-H53IHRBB.js → update-project-PI5DWBVK.js} +4 -3
- package/dist/{update-prompt-M33OTTAJ.js → update-prompt-RQFTEO7Y.js} +3 -2
- package/dist/{update-scenario-FKTQRAFQ.js → update-scenario-G3IU2UIB.js} +4 -3
- package/dist/{update-suite-JJTNOTXN.js → update-suite-TRGGLNR7.js} +4 -3
- package/package.json +20 -18
- package/pnpm-workspace.yaml +20 -3
- package/pyproject.toml +9 -1
- package/src/__tests__/all-tools.integration.test.ts +56 -120
- package/src/__tests__/documentation-fetch-security.integration.test.ts +176 -0
- package/src/__tests__/langwatch-api.unit.test.ts +206 -0
- package/src/__tests__/public-http-request-security.integration.test.ts +118 -0
- package/src/__tests__/report-issue.integration.test.ts +147 -0
- package/src/__tests__/tools.unit.test.ts +24 -0
- package/src/create-mcp-server.ts +43 -29
- package/src/documentation-fetch.ts +111 -0
- package/src/langwatch-api-agents.ts +2 -2
- package/src/langwatch-api.ts +138 -8
- package/src/public-http-request.ts +242 -0
- package/src/tools/get-trace.ts +2 -12
- package/src/tools/report-issue.ts +150 -0
- package/src/tools/search-traces.ts +4 -0
- package/src/utils/format-evaluations.ts +20 -0
- package/tsconfig.json +8 -1
- package/uv.lock +259 -205
- package/dist/chunk-IUP5IHBD.js +0 -71
- package/dist/discover-evaluator-schema-H23XCLNE.js +0 -1402
- package/dist/http-server-GXVEDA7G.js +0 -226
|
@@ -452,4 +452,210 @@ describe("langwatch-api", () => {
|
|
|
452
452
|
).rejects.toThrow("Forbidden: invalid API key");
|
|
453
453
|
});
|
|
454
454
|
});
|
|
455
|
+
|
|
456
|
+
describe("when the API returns a handled-error JSON body", () => {
|
|
457
|
+
const handledErrorBody = {
|
|
458
|
+
error: "invalid_filter",
|
|
459
|
+
message: "Filter 'llm.model' is not supported",
|
|
460
|
+
tips: ["Use 'metadata.model' instead", "See the filter reference"],
|
|
461
|
+
docsUrl: "https://docs.langwatch.ai/api/filters",
|
|
462
|
+
fault: "customer",
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
it("parses code, tips, docsUrl and fault into LangWatchApiError fields", async () => {
|
|
466
|
+
const { searchTraces, LangWatchApiError } = await import(
|
|
467
|
+
"../langwatch-api.js"
|
|
468
|
+
);
|
|
469
|
+
mockErrorResponse(400, JSON.stringify(handledErrorBody));
|
|
470
|
+
|
|
471
|
+
const error = await searchTraces({
|
|
472
|
+
startDate: 1000,
|
|
473
|
+
endDate: 2000,
|
|
474
|
+
}).catch((e) => e);
|
|
475
|
+
|
|
476
|
+
expect(error).toBeInstanceOf(LangWatchApiError);
|
|
477
|
+
expect(error.status).toBe(400);
|
|
478
|
+
expect(error.responseBody).toBe(JSON.stringify(handledErrorBody));
|
|
479
|
+
expect(error.code).toBe("invalid_filter");
|
|
480
|
+
expect(error.tips).toEqual(handledErrorBody.tips);
|
|
481
|
+
expect(error.docsUrl).toBe(handledErrorBody.docsUrl);
|
|
482
|
+
expect(error.fault).toBe("customer");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("formats the message with the server message, Tips section and Docs link", async () => {
|
|
486
|
+
const { searchTraces } = await import("../langwatch-api.js");
|
|
487
|
+
mockErrorResponse(400, JSON.stringify(handledErrorBody));
|
|
488
|
+
|
|
489
|
+
const error = await searchTraces({
|
|
490
|
+
startDate: 1000,
|
|
491
|
+
endDate: 2000,
|
|
492
|
+
}).catch((e) => e);
|
|
493
|
+
|
|
494
|
+
expect(error.message).toContain(
|
|
495
|
+
"LangWatch API error 400: Filter 'llm.model' is not supported"
|
|
496
|
+
);
|
|
497
|
+
expect(error.message).toContain("Tips:");
|
|
498
|
+
expect(error.message).toContain("- Use 'metadata.model' instead");
|
|
499
|
+
expect(error.message).toContain("- See the filter reference");
|
|
500
|
+
expect(error.message).toContain(
|
|
501
|
+
"Docs: https://docs.langwatch.ai/api/filters"
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// A validation failure names the offending field AND the values it would
|
|
506
|
+
// have taken, in `reasons[].meta`. Dropping that leaves the message's own
|
|
507
|
+
// advice ("pick one of the types in this error's expected list") naming a
|
|
508
|
+
// list the caller was never given.
|
|
509
|
+
describe("when the failure carries the values it would have accepted", () => {
|
|
510
|
+
const rejectedTypeBody = {
|
|
511
|
+
error: "validation_error",
|
|
512
|
+
message:
|
|
513
|
+
"Unknown evaluator type. Pick one of the types in this error's expected list and retry.",
|
|
514
|
+
reasons: [
|
|
515
|
+
{
|
|
516
|
+
code: "schema_failure",
|
|
517
|
+
kind: "schema_failure",
|
|
518
|
+
meta: {
|
|
519
|
+
field: "config.evaluatorType",
|
|
520
|
+
expected: ["ragas/response_relevancy", "langevals/llm_boolean"],
|
|
521
|
+
received: "ragas/answer_relevancy",
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/** @scenario A rejection over MCP carries the accepted types */
|
|
528
|
+
it("keeps the reasons on the error", async () => {
|
|
529
|
+
const { createEvaluator } = await import(
|
|
530
|
+
"../langwatch-api-evaluators.js"
|
|
531
|
+
);
|
|
532
|
+
mockErrorResponse(422, JSON.stringify(rejectedTypeBody));
|
|
533
|
+
|
|
534
|
+
const error = await createEvaluator({
|
|
535
|
+
name: "Relevancy",
|
|
536
|
+
config: { evaluatorType: "ragas/answer_relevancy" },
|
|
537
|
+
}).catch((e) => e);
|
|
538
|
+
|
|
539
|
+
expect(error.reasons).toEqual(rejectedTypeBody.reasons);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("names the rejected field and the accepted values in the message", async () => {
|
|
543
|
+
const { createEvaluator } = await import(
|
|
544
|
+
"../langwatch-api-evaluators.js"
|
|
545
|
+
);
|
|
546
|
+
mockErrorResponse(422, JSON.stringify(rejectedTypeBody));
|
|
547
|
+
|
|
548
|
+
const error = await createEvaluator({
|
|
549
|
+
name: "Relevancy",
|
|
550
|
+
config: { evaluatorType: "ragas/answer_relevancy" },
|
|
551
|
+
}).catch((e) => e);
|
|
552
|
+
|
|
553
|
+
expect(error.message).toContain("config.evaluatorType");
|
|
554
|
+
expect(error.message).toContain("ragas/response_relevancy");
|
|
555
|
+
expect(error.message).toContain("langevals/llm_boolean");
|
|
556
|
+
expect(error.message).toContain("ragas/answer_relevancy");
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("parses the tRPC shape using `code` instead of `error`", async () => {
|
|
561
|
+
const { searchTraces } = await import("../langwatch-api.js");
|
|
562
|
+
const body = {
|
|
563
|
+
code: "dataset_not_found",
|
|
564
|
+
message: "Dataset 'abc' not found",
|
|
565
|
+
meta: { datasetId: "abc" },
|
|
566
|
+
httpStatus: 404,
|
|
567
|
+
fault: "customer",
|
|
568
|
+
tips: ["Check the dataset slug"],
|
|
569
|
+
docsUrl: "https://docs.langwatch.ai/datasets",
|
|
570
|
+
reasons: [],
|
|
571
|
+
};
|
|
572
|
+
mockErrorResponse(404, JSON.stringify(body));
|
|
573
|
+
|
|
574
|
+
const error = await searchTraces({
|
|
575
|
+
startDate: 1000,
|
|
576
|
+
endDate: 2000,
|
|
577
|
+
}).catch((e) => e);
|
|
578
|
+
|
|
579
|
+
expect(error.code).toBe("dataset_not_found");
|
|
580
|
+
expect(error.tips).toEqual(["Check the dataset slug"]);
|
|
581
|
+
expect(error.docsUrl).toBe("https://docs.langwatch.ai/datasets");
|
|
582
|
+
expect(error.fault).toBe("customer");
|
|
583
|
+
expect(error.message).toContain("Dataset 'abc' not found");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("omits the Tips and Docs sections when absent", async () => {
|
|
587
|
+
const { searchTraces } = await import("../langwatch-api.js");
|
|
588
|
+
mockErrorResponse(
|
|
589
|
+
401,
|
|
590
|
+
JSON.stringify({ error: "unauthorized", message: "Invalid API key" })
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const error = await searchTraces({
|
|
594
|
+
startDate: 1000,
|
|
595
|
+
endDate: 2000,
|
|
596
|
+
}).catch((e) => e);
|
|
597
|
+
|
|
598
|
+
expect(error.code).toBe("unauthorized");
|
|
599
|
+
expect(error.tips).toBeUndefined();
|
|
600
|
+
expect(error.docsUrl).toBeUndefined();
|
|
601
|
+
expect(error.fault).toBeUndefined();
|
|
602
|
+
expect(error.message).toBe(
|
|
603
|
+
"LangWatch API error 401: Invalid API key"
|
|
604
|
+
);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("ignores an invalid fault value", async () => {
|
|
608
|
+
const { searchTraces } = await import("../langwatch-api.js");
|
|
609
|
+
mockErrorResponse(
|
|
610
|
+
500,
|
|
611
|
+
JSON.stringify({ error: "boom", message: "Boom", fault: "nobody" })
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const error = await searchTraces({
|
|
615
|
+
startDate: 1000,
|
|
616
|
+
endDate: 2000,
|
|
617
|
+
}).catch((e) => e);
|
|
618
|
+
|
|
619
|
+
expect(error.fault).toBeUndefined();
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
describe("when the error body is not a handled-error envelope", () => {
|
|
624
|
+
it("keeps the raw text as the message and sets no extra fields", async () => {
|
|
625
|
+
const { searchTraces, LangWatchApiError } = await import(
|
|
626
|
+
"../langwatch-api.js"
|
|
627
|
+
);
|
|
628
|
+
mockErrorResponse(502, "<html>Bad Gateway</html>");
|
|
629
|
+
|
|
630
|
+
const error = await searchTraces({
|
|
631
|
+
startDate: 1000,
|
|
632
|
+
endDate: 2000,
|
|
633
|
+
}).catch((e) => e);
|
|
634
|
+
|
|
635
|
+
expect(error).toBeInstanceOf(LangWatchApiError);
|
|
636
|
+
expect(error.status).toBe(502);
|
|
637
|
+
expect(error.responseBody).toBe("<html>Bad Gateway</html>");
|
|
638
|
+
expect(error.message).toBe(
|
|
639
|
+
"LangWatch API error 502: <html>Bad Gateway</html>"
|
|
640
|
+
);
|
|
641
|
+
expect(error.code).toBeUndefined();
|
|
642
|
+
expect(error.tips).toBeUndefined();
|
|
643
|
+
expect(error.docsUrl).toBeUndefined();
|
|
644
|
+
expect(error.fault).toBeUndefined();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("treats JSON without error/code/message fields as raw text", async () => {
|
|
648
|
+
const { searchTraces } = await import("../langwatch-api.js");
|
|
649
|
+
const body = JSON.stringify({ unexpected: true });
|
|
650
|
+
mockErrorResponse(500, body);
|
|
651
|
+
|
|
652
|
+
const error = await searchTraces({
|
|
653
|
+
startDate: 1000,
|
|
654
|
+
endDate: 2000,
|
|
655
|
+
}).catch((e) => e);
|
|
656
|
+
|
|
657
|
+
expect(error.code).toBeUndefined();
|
|
658
|
+
expect(error.message).toBe(`LangWatch API error 500: ${body}`);
|
|
659
|
+
});
|
|
660
|
+
});
|
|
455
661
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http";
|
|
2
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
headersForRedirect,
|
|
6
|
+
requestPublicJson,
|
|
7
|
+
resolvePublicDestination,
|
|
8
|
+
} from "../public-http-request.js";
|
|
9
|
+
|
|
10
|
+
describe("public HTTP request security", () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** @scenario Running an HTTP agent rejects unsafe destinations */
|
|
16
|
+
it.each([
|
|
17
|
+
"http://127.0.0.1/secrets",
|
|
18
|
+
"http://2130706433/secrets",
|
|
19
|
+
"http://169.254.169.254/latest/meta-data/",
|
|
20
|
+
"http://10.0.0.1/internal",
|
|
21
|
+
"http://[::1]/secrets",
|
|
22
|
+
"file:///etc/passwd",
|
|
23
|
+
"http://user:password@example.com/",
|
|
24
|
+
])("rejects unsafe destination %s", async (url) => {
|
|
25
|
+
await expect(resolvePublicDestination(url)).rejects.toThrow(/globally routable public addresses/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("fails closed when any resolved address is not public", async () => {
|
|
29
|
+
await expect(
|
|
30
|
+
resolvePublicDestination("https://agent.example/run", async () => [
|
|
31
|
+
{ address: "93.184.216.34", family: 4 },
|
|
32
|
+
{ address: "10.0.0.4", family: 4 },
|
|
33
|
+
])
|
|
34
|
+
).rejects.toThrow(/globally routable public addresses/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("pins a validated public destination", async () => {
|
|
38
|
+
await expect(
|
|
39
|
+
resolvePublicDestination("https://agent.example/run", async () => [{ address: "93.184.216.34", family: 4 }])
|
|
40
|
+
).resolves.toMatchObject({
|
|
41
|
+
address: "93.184.216.34",
|
|
42
|
+
family: 4,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("bounds DNS resolution time", async () => {
|
|
47
|
+
vi.useFakeTimers();
|
|
48
|
+
const resolution = resolvePublicDestination(
|
|
49
|
+
"https://agent.example/run",
|
|
50
|
+
() => new Promise(() => undefined)
|
|
51
|
+
);
|
|
52
|
+
const rejection = expect(resolution).rejects.toThrow(
|
|
53
|
+
/destination could not be resolved/
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
57
|
+
|
|
58
|
+
await rejection;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** @scenario HTTP agent redirects are revalidated */
|
|
62
|
+
it("rejects private redirect targets and strips cross-origin secrets", async () => {
|
|
63
|
+
await expect(
|
|
64
|
+
resolvePublicDestination("http://127.0.0.1/redirect-target")
|
|
65
|
+
).rejects.toThrow(/globally routable public addresses/);
|
|
66
|
+
|
|
67
|
+
expect(
|
|
68
|
+
headersForRedirect(
|
|
69
|
+
{
|
|
70
|
+
Authorization: "Bearer secret",
|
|
71
|
+
Cookie: "session=secret",
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"X-Api-Key": "secret",
|
|
74
|
+
"X-Request-Id": "request-1",
|
|
75
|
+
},
|
|
76
|
+
true
|
|
77
|
+
)
|
|
78
|
+
).toEqual({
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
"X-Request-Id": "request-1",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("real HTTP request", () => {
|
|
85
|
+
let server: Server;
|
|
86
|
+
let port: number;
|
|
87
|
+
let hits = 0;
|
|
88
|
+
|
|
89
|
+
beforeAll(async () => {
|
|
90
|
+
server = createServer((_request, response) => {
|
|
91
|
+
hits += 1;
|
|
92
|
+
response.setHeader("Content-Type", "application/json");
|
|
93
|
+
response.end(JSON.stringify({ secret: true }));
|
|
94
|
+
});
|
|
95
|
+
await new Promise<void>((resolve) => {
|
|
96
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
97
|
+
});
|
|
98
|
+
const address = server.address();
|
|
99
|
+
port = typeof address === "object" && address ? address.port : 0;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(async () => {
|
|
103
|
+
await new Promise<void>((resolve, reject) => {
|
|
104
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("blocks loopback before opening a connection", async () => {
|
|
109
|
+
await expect(
|
|
110
|
+
requestPublicJson(`http://127.0.0.1:${port}/secrets`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
body: "{}",
|
|
113
|
+
})
|
|
114
|
+
).rejects.toThrow(/globally routable public addresses/);
|
|
115
|
+
expect(hits).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import {
|
|
4
|
+
afterAll,
|
|
5
|
+
beforeAll,
|
|
6
|
+
beforeEach,
|
|
7
|
+
describe,
|
|
8
|
+
expect,
|
|
9
|
+
it,
|
|
10
|
+
} from "vitest";
|
|
11
|
+
import { initConfig } from "../config.js";
|
|
12
|
+
import { createMcpServer } from "../create-mcp-server.js";
|
|
13
|
+
import { handleReportIssue } from "../tools/report-issue.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Integration tests for the report_issue tool against a REAL local HTTP
|
|
17
|
+
* server. Corresponds to specs/mcp-server/report-issue-tool.feature.
|
|
18
|
+
*/
|
|
19
|
+
describe("report_issue", () => {
|
|
20
|
+
let server: Server;
|
|
21
|
+
let endpoint: string;
|
|
22
|
+
let received: { headers: Record<string, unknown>; body: any }[];
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
server = createServer((req, res) => {
|
|
26
|
+
let data = "";
|
|
27
|
+
req.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
|
28
|
+
req.on("end", () => {
|
|
29
|
+
received.push({ headers: { ...req.headers }, body: JSON.parse(data) });
|
|
30
|
+
res.writeHead(201, { "content-type": "application/json" });
|
|
31
|
+
res.end(JSON.stringify({ id: "report-mcp-1" }));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
35
|
+
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
await new Promise((resolve) => server.close(resolve));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
received = [];
|
|
44
|
+
initConfig({ endpoint, apiKey: undefined });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("given the registered tool set", () => {
|
|
48
|
+
/** @scenario "The report tool is listed with an agent-facing description" */
|
|
49
|
+
it("lists report_issue with the agent-facing consent description", () => {
|
|
50
|
+
const server = createMcpServer();
|
|
51
|
+
const tools = (
|
|
52
|
+
server as unknown as {
|
|
53
|
+
_registeredTools: Record<string, { description?: string }>;
|
|
54
|
+
}
|
|
55
|
+
)._registeredTools;
|
|
56
|
+
expect(Object.keys(tools)).toContain("report_issue");
|
|
57
|
+
const description = tools.report_issue?.description ?? "";
|
|
58
|
+
expect(description).toMatch(/whenever you struggled/i);
|
|
59
|
+
expect(description).toMatch(/ask the user for permission first/i);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("given the user did not approve sending", () => {
|
|
64
|
+
/** @scenario "Calls without user approval are rejected with instructions" */
|
|
65
|
+
it("refuses with instructions to ask the user first", async () => {
|
|
66
|
+
await expect(
|
|
67
|
+
handleReportIssue({
|
|
68
|
+
user_approved: false,
|
|
69
|
+
title: "broken tool",
|
|
70
|
+
summary: "search_traces 500s",
|
|
71
|
+
}),
|
|
72
|
+
).rejects.toThrow(/ask the user/i);
|
|
73
|
+
expect(received).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("given nothing to report", () => {
|
|
78
|
+
it("asks for a summary or session content", async () => {
|
|
79
|
+
await expect(
|
|
80
|
+
handleReportIssue({ user_approved: true, title: "empty" }),
|
|
81
|
+
).rejects.toThrow(/Nothing to report/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("when the user approved a summary report", () => {
|
|
86
|
+
/** @scenario "Reporting an issue through MCP reaches the LangWatch backend" */
|
|
87
|
+
it("delivers it marked as coming from MCP and returns a thank-you", async () => {
|
|
88
|
+
const text = await handleReportIssue({
|
|
89
|
+
user_approved: true,
|
|
90
|
+
title: "evaluator setup confusing",
|
|
91
|
+
summary: "the evaluator wizard 404s on step 2",
|
|
92
|
+
agent: "claude-code",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(received).toHaveLength(1);
|
|
96
|
+
expect(received[0]?.body.source).toBe("mcp");
|
|
97
|
+
expect(received[0]?.body.kind).toBe("summary");
|
|
98
|
+
expect(received[0]?.body.title).toBe("evaluator setup confusing");
|
|
99
|
+
expect(text).toContain("report-mcp-1");
|
|
100
|
+
expect(text.toLowerCase()).toContain("thank you");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("when the title itself includes a secret", () => {
|
|
105
|
+
it("redacts it before sending", async () => {
|
|
106
|
+
await handleReportIssue({
|
|
107
|
+
user_approved: true,
|
|
108
|
+
title: "X-Api-Key sk-proj-abcdefghijklmnopqrstuvwxyz123456 rejected",
|
|
109
|
+
summary: "the key was rejected",
|
|
110
|
+
});
|
|
111
|
+
expect(received[0]?.body.title).toBe("X-Api-Key [SECRET] rejected");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("when session content includes secrets", () => {
|
|
116
|
+
/** @scenario "Session content passed through MCP is redacted before sending" */
|
|
117
|
+
it("redacts them locally before sending", async () => {
|
|
118
|
+
await handleReportIssue({
|
|
119
|
+
user_approved: true,
|
|
120
|
+
title: "agent stuck",
|
|
121
|
+
session_content: JSON.stringify({
|
|
122
|
+
role: "user",
|
|
123
|
+
content: "my key sk-proj-abcdefghijklmnopqrstuvwxyz123456 failed",
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const wire = JSON.stringify(received[0]?.body);
|
|
128
|
+
expect(wire).not.toContain("sk-proj-abcdefghijklmnopqrstuvwxyz123456");
|
|
129
|
+
expect(received[0]?.body.sessionData).toContain("[SECRET]");
|
|
130
|
+
expect(received[0]?.body.kind).toBe("full_session");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("when an API key is configured", () => {
|
|
135
|
+
it("attaches it so the report links to the project", async () => {
|
|
136
|
+
initConfig({ endpoint, apiKey: "sk-lw-mcp-key-abcdef1234567890" });
|
|
137
|
+
await handleReportIssue({
|
|
138
|
+
user_approved: true,
|
|
139
|
+
title: "linked",
|
|
140
|
+
summary: "linked report",
|
|
141
|
+
});
|
|
142
|
+
expect(received[0]?.headers.authorization).toBe(
|
|
143
|
+
"Bearer sk-lw-mcp-key-abcdef1234567890",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -168,6 +168,30 @@ describe("handleSearchTraces()", () => {
|
|
|
168
168
|
expect(result).toContain("timeout");
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
describe("when a trace has evaluation results", () => {
|
|
172
|
+
it("shows pass/fail status per evaluation without requiring get_trace", async () => {
|
|
173
|
+
mockSearchTraces.mockResolvedValue({
|
|
174
|
+
traces: [
|
|
175
|
+
{
|
|
176
|
+
trace_id: "trace-eval",
|
|
177
|
+
input: { value: "" },
|
|
178
|
+
output: { value: "" },
|
|
179
|
+
evaluations: [
|
|
180
|
+
{ name: "faithfulness", passed: true, score: 0.92 },
|
|
181
|
+
{ evaluator_id: "toxicity-check", passed: false, label: "flagged" },
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
pagination: { totalHits: 1 },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const result = await handleSearchTraces({});
|
|
189
|
+
|
|
190
|
+
expect(result).toContain("**faithfulness**: PASSED (score: 0.92)");
|
|
191
|
+
expect(result).toContain("**toxicity-check**: FAILED [flagged]");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
171
195
|
describe("when no traces are found", () => {
|
|
172
196
|
it("returns a no-results message", async () => {
|
|
173
197
|
mockSearchTraces.mockResolvedValue({ traces: [] });
|
package/src/create-mcp-server.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
-
import packageJson from "../package.json"
|
|
4
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
5
5
|
import { requireApiKey } from "./config.js";
|
|
6
|
+
import { fetchDocumentation } from "./documentation-fetch.js";
|
|
6
7
|
import {
|
|
7
8
|
createDatasetSchema,
|
|
8
9
|
datasetColumnDefinitionSchema,
|
|
@@ -70,20 +71,46 @@ function registerTools(server: McpServer): void {
|
|
|
70
71
|
),
|
|
71
72
|
},
|
|
72
73
|
withToolLogging("fetch_langwatch_docs", async ({ url }) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (!urlToFetch.startsWith("http")) {
|
|
78
|
-
if (!urlToFetch.startsWith("/")) {
|
|
79
|
-
urlToFetch = "/" + urlToFetch;
|
|
80
|
-
}
|
|
81
|
-
urlToFetch = "https://langwatch.ai/docs" + urlToFetch;
|
|
82
|
-
}
|
|
83
|
-
const response = await fetch(urlToFetch);
|
|
74
|
+
const text = await fetchDocumentation("langwatch", url);
|
|
75
|
+
return { content: [{ type: "text", text }] };
|
|
76
|
+
})
|
|
77
|
+
);
|
|
84
78
|
|
|
79
|
+
server.tool(
|
|
80
|
+
"report_issue",
|
|
81
|
+
"Report an issue to the LangWatch team: anything that did not work, confused you, or took trial and error while using LangWatch (docs, MCP tools, CLI, SDKs, the platform). Use it whenever you struggled, it is how rough edges get fixed for every agent. Ask the user for permission first, then call with user_approved=true. Works without an API key. Put VERBATIM error messages in the summary; optionally attach raw session content. Secrets, API keys, emails and phone numbers are redacted locally before anything is sent.",
|
|
82
|
+
{
|
|
83
|
+
user_approved: z
|
|
84
|
+
.boolean()
|
|
85
|
+
.describe(
|
|
86
|
+
"Must be true, and only after the user explicitly agreed to send this report to LangWatch"
|
|
87
|
+
),
|
|
88
|
+
title: z.string().max(300).describe("One-line description of the issue"),
|
|
89
|
+
summary: z
|
|
90
|
+
.string()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe(
|
|
93
|
+
"What you were trying to do, what went wrong (verbatim errors), and what you had to figure out the hard way"
|
|
94
|
+
),
|
|
95
|
+
session_content: z
|
|
96
|
+
.string()
|
|
97
|
+
.optional()
|
|
98
|
+
.describe(
|
|
99
|
+
"Optional raw session transcript or log excerpt (JSONL or plain text); redacted locally before sending"
|
|
100
|
+
),
|
|
101
|
+
contact_email: z
|
|
102
|
+
.string()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("Optional contact email for follow-up"),
|
|
105
|
+
agent: z
|
|
106
|
+
.string()
|
|
107
|
+
.optional()
|
|
108
|
+
.describe("Which coding agent this is, e.g. claude-code, codex, cursor"),
|
|
109
|
+
},
|
|
110
|
+
withToolLogging("report_issue", async (params) => {
|
|
111
|
+
const { handleReportIssue } = await import("./tools/report-issue.js");
|
|
85
112
|
return {
|
|
86
|
-
content: [{ type: "text", text: await
|
|
113
|
+
content: [{ type: "text", text: await handleReportIssue(params) }],
|
|
87
114
|
};
|
|
88
115
|
})
|
|
89
116
|
);
|
|
@@ -100,21 +127,8 @@ function registerTools(server: McpServer): void {
|
|
|
100
127
|
),
|
|
101
128
|
},
|
|
102
129
|
withToolLogging("fetch_scenario_docs", async ({ url }) => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
urlToFetch += ".md";
|
|
106
|
-
}
|
|
107
|
-
if (!urlToFetch.startsWith("http")) {
|
|
108
|
-
if (!urlToFetch.startsWith("/")) {
|
|
109
|
-
urlToFetch = "/" + urlToFetch;
|
|
110
|
-
}
|
|
111
|
-
urlToFetch = "https://langwatch.ai" + urlToFetch;
|
|
112
|
-
}
|
|
113
|
-
const response = await fetch(urlToFetch);
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
content: [{ type: "text", text: await response.text() }],
|
|
117
|
-
};
|
|
130
|
+
const text = await fetchDocumentation("scenario", url);
|
|
131
|
+
return { content: [{ type: "text", text }] };
|
|
118
132
|
})
|
|
119
133
|
);
|
|
120
134
|
|
|
@@ -139,7 +153,7 @@ function registerTools(server: McpServer): void {
|
|
|
139
153
|
.string()
|
|
140
154
|
.optional()
|
|
141
155
|
.describe(
|
|
142
|
-
"When category is 'evaluators', provide a specific evaluator type (e.g. 'langevals/
|
|
156
|
+
"When category is 'evaluators', provide a specific evaluator type (e.g. 'langevals/llm_boolean') to get its full schema details"
|
|
143
157
|
),
|
|
144
158
|
},
|
|
145
159
|
async ({ category, evaluatorType }) => {
|