Unified · Split
| @@ -33,6 +33,19 @@ const put = <K extends string, V>(record: Record<K, V>, key: K, value: V) => { · 32 unmodified lines | ||
| 33 | 33 | record[key] = value; |
| 34 | 34 | }; |
| 35 | 35 | |
| 36 | +const expectNullableShapeContains = ( | |
| 37 | + catalog: CatalogV1, | |
| 38 | + nullableShapeId: ReturnType<typeof ShapeSymbolIdSchema.make>, | |
| 39 | + expectedInnerShapeId: ReturnType<typeof ShapeSymbolIdSchema.make>, | |
| 40 | +) => { | |
| 41 | + const shape = catalog.symbols[nullableShapeId]; | |
| 42 | + if (shape?.kind !== "shape" || shape.node.type !== "anyOf") { | |
| 43 | + throw new Error("Expected nullable anyOf shape"); | |
| 44 | + } | |
| 45 | + | |
| 46 | + expect(shape.node.items).toContain(expectedInnerShapeId); | |
| 47 | +}; | |
| 48 | + | |
| 36 | 49 | const docId = DocumentIdSchema.make("doc_primary"); |
| 37 | 50 | const baseProvenance = (pointer: string): ProvenanceRef[] => [{ |
| 38 | 51 | relation: "declared", |
| @@ -630,7 +643,6 @@ describe("IR catalog", () => { · 591 unmodified lines | ||
| 630 | 643 | |
| 631 | 644 | const descriptor = projected.toolDescriptors[CapabilityIdSchema.make("cap_events_update")]; |
| 632 | 645 | expect(descriptor.toolPath).toEqual(["google", "calendar", "events", "update"]); |
| 633 | − expect(descriptor.resultShapeId).toBe(ShapeSymbolIdSchema.make("shape_event_result")); | |
| 634 | 646 | |
| 635 | 647 | const callShape = projected.catalog.symbols[descriptor.callShapeId]; |
| 636 | 648 | expect(callShape?.kind).toBe("shape"); |
| @@ -646,6 +658,19 @@ describe("IR catalog", () => { · 9 unmodified lines | ||
| 646 | 658 | ]); |
| 647 | 659 | expect(callShape.node.required).toEqual(["calendarId", "eventId", "body"]); |
| 648 | 660 | |
| 661 | + const resultShape = descriptor.resultShapeId | |
| 662 | + ? projected.catalog.symbols[descriptor.resultShapeId] | |
| 663 | + : undefined; | |
| 664 | + if (resultShape?.kind !== "shape" || resultShape.node.type !== "object") { | |
| 665 | + throw new Error("Expected projected HTTP result envelope"); | |
| 666 | + } | |
| 667 | + expect(Object.keys(resultShape.node.fields)).toEqual(["data", "error", "headers", "status"]); | |
| 668 | + expectNullableShapeContains( | |
| 669 | + projected.catalog, | |
| 670 | + resultShape.node.fields.data.shapeId, | |
| 671 | + ShapeSymbolIdSchema.make("shape_event_result"), | |
| 672 | + ); | |
| 673 | + | |
| 649 | 674 | const searchDoc = projected.searchDocs[CapabilityIdSchema.make("cap_events_update")]; |
| 650 | 675 | expect(searchDoc.authHints).toContain("oauth2"); |
| 651 | 676 | expect(searchDoc.tags).toEqual(["calendar", "events"]); |
| @@ -680,7 +705,18 @@ describe("IR catalog", () => { · 28 unmodified lines | ||
| 680 | 705 | } |
| 681 | 706 | |
| 682 | 707 | expect(Object.keys(callShape.node.fields)).toEqual(["id", "input", "select"]); |
| 683 | − expect(descriptor.resultShapeId).toBe(ShapeSymbolIdSchema.make("shape_graphql_result")); | |
| 708 | + const resultShape = descriptor.resultShapeId | |
| 709 | + ? projected.catalog.symbols[descriptor.resultShapeId] | |
| 710 | + : undefined; | |
| 711 | + if (resultShape?.kind !== "shape" || resultShape.node.type !== "object") { | |
| 712 | + throw new Error("Expected projected GraphQL result envelope"); | |
| 713 | + } | |
| 714 | + expect(Object.keys(resultShape.node.fields)).toEqual(["data", "error", "headers", "status"]); | |
| 715 | + expectNullableShapeContains( | |
| 716 | + projected.catalog, | |
| 717 | + resultShape.node.fields.data.shapeId, | |
| 718 | + ShapeSymbolIdSchema.make("shape_graphql_result"), | |
| 719 | + ); | |
| 684 | 720 | }); |
| 685 | 721 | |
| 686 | 722 | it("builds inspect views and invocation plans from the projected graph", () => { |
| @@ -622,12 +622,17 @@ const responseContentCandidates = ( · 621 unmodified lines | ||
| 622 | 622 | content.shapeId ? [{ mediaType: content.mediaType, shapeId: content.shapeId }] : [], |
| 623 | 623 | ); |
| 624 | 624 | |
| 625 | −const projectResultShapeFromResponses = ( | |
| 625 | +type RankedResponseVariantEntry = { | |
| 626 | + variant: ResponseSet["variants"][number]; | |
| 627 | + response: ResponseSymbol; | |
| 628 | + score: number; | |
| 629 | +}; | |
| 630 | + | |
| 631 | +const responseVariantEntries = ( | |
| 626 | 632 | catalog: CatalogV1, |
| 627 | − capability: Capability, | |
| 628 | 633 | responseSet: ResponseSet, |
| 629 | −): ShapeSymbolId | undefined => { | |
| 630 | − const ranked = [...responseSet.variants] | |
| 634 | +): RankedResponseVariantEntry[] => | |
| 635 | + [...responseSet.variants] | |
| 631 | 636 | .map((variant) => { |
| 632 | 637 | const response = getResponseSymbol(catalog, variant.responseId); |
| 633 | 638 | return response |
| @@ -638,14 +643,27 @@ const projectResultShapeFromResponses = ( · 4 unmodified lines | ||
| 638 | 643 | } |
| 639 | 644 | : null; |
| 640 | 645 | }) |
| 641 | − .filter((entry): entry is { variant: ResponseSet["variants"][number]; response: ResponseSymbol; score: number } => entry !== null) | |
| 642 | − .sort((left, right) => right.score - left.score); | |
| 646 | + .filter((entry): entry is RankedResponseVariantEntry => entry !== null); | |
| 643 | 647 | |
| 644 | − const preferred = ranked.some(({ score }) => score >= 60) | |
| 645 | − ? ranked.filter(({ score }) => score >= 60) | |
| 646 | − : ranked.filter(({ score }) => score === (ranked[0]?.score ?? 0)); | |
| 648 | +const isSuccessStatusMatch = (match: StatusMatch): boolean => { | |
| 649 | + switch (match.kind) { | |
| 650 | + case "exact": | |
| 651 | + return match.status >= 200 && match.status < 300; | |
| 652 | + case "range": | |
| 653 | + return match.value === "2XX"; | |
| 654 | + case "default": | |
| 655 | + return false; | |
| 656 | + } | |
| 657 | +}; | |
| 647 | 658 | |
| 648 | − const jsonCandidates = preferred.flatMap(({ response }) => | |
| 659 | +const contentShapeIdFromResponseEntries = ( | |
| 660 | + catalog: CatalogV1, | |
| 661 | + capability: Capability, | |
| 662 | + label: string, | |
| 663 | + title: string, | |
| 664 | + entries: readonly RankedResponseVariantEntry[], | |
| 665 | +): ShapeSymbolId | undefined => { | |
| 666 | + const jsonCandidates = entries.flatMap(({ response }) => | |
| 649 | 667 | responseContentCandidates(response).filter((candidate) => isJsonMediaType(candidate.mediaType)), |
| 650 | 668 | ); |
| 651 | 669 | |
| @@ -657,12 +675,12 @@ const projectResultShapeFromResponses = ( · 5 unmodified lines | ||
| 657 | 675 | mediaType: candidate.mediaType, |
| 658 | 676 | shapeId: candidate.shapeId, |
| 659 | 677 | })), |
| 660 | − `responseSet:${responseSet.id}:json`, | |
| 678 | + `${label}:json`, | |
| 661 | 679 | ); |
| 662 | 680 | } |
| 663 | 681 | |
| 664 | 682 | const fallbackShapeIds = unique( |
| 665 | − preferred.flatMap(({ response }) => | |
| 683 | + entries.flatMap(({ response }) => | |
| 666 | 684 | responseContentCandidates(response).map((candidate) => candidate.shapeId), |
| 667 | 685 | ), |
| 668 | 686 | ); |
| @@ -677,8 +695,8 @@ const projectResultShapeFromResponses = ( · 8 unmodified lines | ||
| 677 | 695 | |
| 678 | 696 | return createSyntheticShape(catalog, { |
| 679 | 697 | capability, |
| 680 | − label: `responseSet:${responseSet.id}:union`, | |
| 681 | − title: `${capability.surface.title ?? capability.id} result`, | |
| 698 | + label: `${label}:union`, | |
| 699 | + title, | |
| 682 | 700 | node: { |
| 683 | 701 | type: "anyOf", |
| 684 | 702 | items: fallbackShapeIds, |
| @@ -692,6 +710,297 @@ const projectResultShapeFromResponses = ( · 7 unmodified lines | ||
| 692 | 710 | }); |
| 693 | 711 | }; |
| 694 | 712 | |
| 713 | +const projectResultShapeFromResponses = ( | |
| 714 | + catalog: CatalogV1, | |
| 715 | + capability: Capability, | |
| 716 | + responseSet: ResponseSet, | |
| 717 | +): ShapeSymbolId | undefined => | |
| 718 | + contentShapeIdFromResponseEntries( | |
| 719 | + catalog, | |
| 720 | + capability, | |
| 721 | + `responseSet:${responseSet.id}`, | |
| 722 | + `${capability.surface.title ?? capability.id} result`, | |
| 723 | + responseVariantEntries(catalog, responseSet).filter(({ variant }) => isSuccessStatusMatch(variant.match)), | |
| 724 | + ); | |
| 725 | + | |
| 726 | +const projectErrorShapeFromResponses = ( | |
| 727 | + catalog: CatalogV1, | |
| 728 | + capability: Capability, | |
| 729 | + responseSet: ResponseSet, | |
| 730 | +): ShapeSymbolId | undefined => | |
| 731 | + contentShapeIdFromResponseEntries( | |
| 732 | + catalog, | |
| 733 | + capability, | |
| 734 | + `responseSet:${responseSet.id}:error`, | |
| 735 | + `${capability.surface.title ?? capability.id} error`, | |
| 736 | + responseVariantEntries(catalog, responseSet).filter(({ variant }) => !isSuccessStatusMatch(variant.match)), | |
| 737 | + ); | |
| 738 | + | |
| 739 | +const scalarShape = ( | |
| 740 | + catalog: CatalogV1, | |
| 741 | + capability: Capability, | |
| 742 | + input: { | |
| 743 | + label: string; | |
| 744 | + title: string; | |
| 745 | + scalar: "string" | "number" | "integer" | "boolean" | "null" | "bytes"; | |
| 746 | + }, | |
| 747 | +): ShapeSymbolId => | |
| 748 | + createSyntheticShape(catalog, { | |
| 749 | + capability, | |
| 750 | + label: input.label, | |
| 751 | + title: input.title, | |
| 752 | + node: { | |
| 753 | + type: "scalar", | |
| 754 | + scalar: input.scalar, | |
| 755 | + }, | |
| 756 | + }); | |
| 757 | + | |
| 758 | +const constNullShape = ( | |
| 759 | + catalog: CatalogV1, | |
| 760 | + capability: Capability, | |
| 761 | + label: string, | |
| 762 | +): ShapeSymbolId => | |
| 763 | + createSyntheticShape(catalog, { | |
| 764 | + capability, | |
| 765 | + label, | |
| 766 | + title: "null", | |
| 767 | + node: { | |
| 768 | + type: "const", | |
| 769 | + value: null, | |
| 770 | + }, | |
| 771 | + }); | |
| 772 | + | |
| 773 | +const unknownShape = ( | |
| 774 | + catalog: CatalogV1, | |
| 775 | + capability: Capability, | |
| 776 | + input: { | |
| 777 | + label: string; | |
| 778 | + title: string; | |
| 779 | + reason: string; | |
| 780 | + }, | |
| 781 | +): ShapeSymbolId => | |
| 782 | + createSyntheticShape(catalog, { | |
| 783 | + capability, | |
| 784 | + label: input.label, | |
| 785 | + title: input.title, | |
| 786 | + node: { | |
| 787 | + type: "unknown", | |
| 788 | + reason: input.reason, | |
| 789 | + }, | |
| 790 | + }); | |
| 791 | + | |
| 792 | +const nullableShape = ( | |
| 793 | + catalog: CatalogV1, | |
| 794 | + capability: Capability, | |
| 795 | + input: { | |
| 796 | + label: string; | |
| 797 | + title: string; | |
| 798 | + baseShapeId: ShapeSymbolId; | |
| 799 | + }, | |
| 800 | +): ShapeSymbolId => { | |
| 801 | + const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`); | |
| 802 | + if (input.baseShapeId === nullShapeId) { | |
| 803 | + return nullShapeId; | |
| 804 | + } | |
| 805 | + | |
| 806 | + return createSyntheticShape(catalog, { | |
| 807 | + capability, | |
| 808 | + label: `${input.label}:nullable`, | |
| 809 | + title: input.title, | |
| 810 | + node: { | |
| 811 | + type: "anyOf", | |
| 812 | + items: unique([input.baseShapeId, nullShapeId]), | |
| 813 | + }, | |
| 814 | + }); | |
| 815 | +}; | |
| 816 | + | |
| 817 | +const headersShape = ( | |
| 818 | + catalog: CatalogV1, | |
| 819 | + capability: Capability, | |
| 820 | + label: string, | |
| 821 | +): ShapeSymbolId => { | |
| 822 | + const valueShapeId = scalarShape(catalog, capability, { | |
| 823 | + label: `${label}:value`, | |
| 824 | + title: "Header value", | |
| 825 | + scalar: "string", | |
| 826 | + }); | |
| 827 | + | |
| 828 | + return createSyntheticShape(catalog, { | |
| 829 | + capability, | |
| 830 | + label, | |
| 831 | + title: "Response headers", | |
| 832 | + node: { | |
| 833 | + type: "object", | |
| 834 | + fields: {}, | |
| 835 | + additionalProperties: valueShapeId, | |
| 836 | + }, | |
| 837 | + }); | |
| 838 | +}; | |
| 839 | + | |
| 840 | +const shapeFieldShapeId = ( | |
| 841 | + catalog: CatalogV1, | |
| 842 | + shapeId: ShapeSymbolId | undefined, | |
| 843 | + fieldName: string, | |
| 844 | + seen = new Set<ShapeSymbolId>(), | |
| 845 | +): ShapeSymbolId | undefined => { | |
| 846 | + if (!shapeId || seen.has(shapeId)) { | |
| 847 | + return undefined; | |
| 848 | + } | |
| 849 | + | |
| 850 | + const shape = getShape(catalog, shapeId); | |
| 851 | + if (!shape) { | |
| 852 | + return undefined; | |
| 853 | + } | |
| 854 | + | |
| 855 | + const nextSeen = new Set(seen); | |
| 856 | + nextSeen.add(shapeId); | |
| 857 | + | |
| 858 | + switch (shape.node.type) { | |
| 859 | + case "ref": | |
| 860 | + return shapeFieldShapeId(catalog, shape.node.target, fieldName, nextSeen); | |
| 861 | + case "object": | |
| 862 | + return shape.node.fields[fieldName]?.shapeId; | |
| 863 | + case "allOf": | |
| 864 | + for (const item of shape.node.items) { | |
| 865 | + const found = shapeFieldShapeId(catalog, item, fieldName, nextSeen); | |
| 866 | + if (found) { | |
| 867 | + return found; | |
| 868 | + } | |
| 869 | + } | |
| 870 | + return undefined; | |
| 871 | + default: | |
| 872 | + return undefined; | |
| 873 | + } | |
| 874 | +}; | |
| 875 | + | |
| 876 | +const projectExecutionResultShape = ( | |
| 877 | + catalog: CatalogV1, | |
| 878 | + capability: Capability, | |
| 879 | + executable: Executable, | |
| 880 | + responseSet: ResponseSet, | |
| 881 | +): ShapeSymbolId => { | |
| 882 | + const genericDataShape = unknownShape(catalog, capability, { | |
| 883 | + label: `executionResult:${capability.id}:data:unknown`, | |
| 884 | + title: "Response data", | |
| 885 | + reason: `Execution result data for ${capability.id} is not statically known`, | |
| 886 | + }); | |
| 887 | + const genericErrorShape = unknownShape(catalog, capability, { | |
| 888 | + label: `executionResult:${capability.id}:error:unknown`, | |
| 889 | + title: "Response error", | |
| 890 | + reason: `Execution result error for ${capability.id} is not statically known`, | |
| 891 | + }); | |
| 892 | + const genericHeadersShape = headersShape( | |
| 893 | + catalog, | |
| 894 | + capability, | |
| 895 | + `executionResult:${capability.id}:headers`, | |
| 896 | + ); | |
| 897 | + const genericStatusShape = scalarShape(catalog, capability, { | |
| 898 | + label: `executionResult:${capability.id}:status`, | |
| 899 | + title: "Response status", | |
| 900 | + scalar: "integer", | |
| 901 | + }); | |
| 902 | + | |
| 903 | + let dataShapeId: ShapeSymbolId | undefined; | |
| 904 | + let errorShapeId: ShapeSymbolId | undefined; | |
| 905 | + let headersShapeId: ShapeSymbolId = genericHeadersShape; | |
| 906 | + let statusShapeId: ShapeSymbolId = genericStatusShape; | |
| 907 | + | |
| 908 | + switch (executable.protocol) { | |
| 909 | + case "http": | |
| 910 | + dataShapeId = projectResultShapeFromResponses(catalog, capability, responseSet); | |
| 911 | + errorShapeId = projectErrorShapeFromResponses(catalog, capability, responseSet); | |
| 912 | + break; | |
| 913 | + case "graphql": | |
| 914 | + dataShapeId = | |
| 915 | + shapeFieldShapeId(catalog, executable.resultShapeId, "data") | |
| 916 | + ?? shapeFieldShapeId(catalog, executable.resultShapeId, "body") | |
| 917 | + ?? executable.resultShapeId; | |
| 918 | + errorShapeId = | |
| 919 | + shapeFieldShapeId(catalog, executable.resultShapeId, "error") | |
| 920 | + ?? shapeFieldShapeId(catalog, executable.resultShapeId, "errors"); | |
| 921 | + headersShapeId = | |
| 922 | + shapeFieldShapeId(catalog, executable.resultShapeId, "headers") | |
| 923 | + ?? genericHeadersShape; | |
| 924 | + statusShapeId = | |
| 925 | + shapeFieldShapeId(catalog, executable.resultShapeId, "status") | |
| 926 | + ?? genericStatusShape; | |
| 927 | + break; | |
| 928 | + case "mcp": | |
| 929 | + dataShapeId = executable.outputShapeId; | |
| 930 | + errorShapeId = undefined; | |
| 931 | + statusShapeId = constNullShape( | |
| 932 | + catalog, | |
| 933 | + capability, | |
| 934 | + `executionResult:${capability.id}:status:null`, | |
| 935 | + ); | |
| 936 | + break; | |
| 937 | + } | |
| 938 | + | |
| 939 | + const dataFieldShapeId = nullableShape(catalog, capability, { | |
| 940 | + label: `executionResult:${capability.id}:data`, | |
| 941 | + title: "Result data", | |
| 942 | + baseShapeId: dataShapeId ?? genericDataShape, | |
| 943 | + }); | |
| 944 | + const errorFieldShapeId = nullableShape(catalog, capability, { | |
| 945 | + label: `executionResult:${capability.id}:error`, | |
| 946 | + title: "Result error", | |
| 947 | + baseShapeId: errorShapeId ?? genericErrorShape, | |
| 948 | + }); | |
| 949 | + const statusFieldShapeId = nullableShape(catalog, capability, { | |
| 950 | + label: `executionResult:${capability.id}:status`, | |
| 951 | + title: "Result status", | |
| 952 | + baseShapeId: statusShapeId, | |
| 953 | + }); | |
| 954 | + | |
| 955 | + return createSyntheticShape(catalog, { | |
| 956 | + capability, | |
| 957 | + label: `executionResult:${capability.id}`, | |
| 958 | + title: `${capability.surface.title ?? capability.id} result`, | |
| 959 | + node: { | |
| 960 | + type: "object", | |
| 961 | + fields: { | |
| 962 | + data: { | |
| 963 | + shapeId: dataFieldShapeId, | |
| 964 | + docs: { | |
| 965 | + description: "Successful result payload when available.", | |
| 966 | + }, | |
| 967 | + }, | |
| 968 | + error: { | |
| 969 | + shapeId: errorFieldShapeId, | |
| 970 | + docs: { | |
| 971 | + description: "Error payload when the remote execution completed but failed.", | |
| 972 | + }, | |
| 973 | + }, | |
| 974 | + headers: { | |
| 975 | + shapeId: headersShapeId, | |
| 976 | + docs: { | |
| 977 | + description: "Response headers when available.", | |
| 978 | + }, | |
| 979 | + }, | |
| 980 | + status: { | |
| 981 | + shapeId: statusFieldShapeId, | |
| 982 | + docs: { | |
| 983 | + description: "Transport status code when available.", | |
| 984 | + }, | |
| 985 | + }, | |
| 986 | + }, | |
| 987 | + required: ["data", "error", "headers", "status"], | |
| 988 | + additionalProperties: false, | |
| 989 | + }, | |
| 990 | + diagnostic: { | |
| 991 | + level: "info", | |
| 992 | + code: "projection_result_shape_synthesized", | |
| 993 | + message: `Synthesized execution result envelope for ${capability.id}`, | |
| 994 | + relatedSymbolIds: unique([ | |
| 995 | + dataFieldShapeId, | |
| 996 | + errorFieldShapeId, | |
| 997 | + headersShapeId, | |
| 998 | + statusFieldShapeId, | |
| 999 | + ]), | |
| 1000 | + }, | |
| 1001 | + }); | |
| 1002 | +}; | |
| 1003 | + | |
| 695 | 1004 | const groupFieldShape = ( |
| 696 | 1005 | catalog: CatalogV1, |
| 697 | 1006 | capability: Capability, |
| @@ -1073,15 +1382,15 @@ const projectCapability = ( · 375 unmodified lines | ||
| 1073 | 1382 | switch (executable.protocol) { |
| 1074 | 1383 | case "http": |
| 1075 | 1384 | callShapeId = projectHttpCallShape(catalog, capability, executable); |
| 1076 | − resultShapeId = projectResultShapeFromResponses(catalog, capability, responseSet); | |
| 1385 | + resultShapeId = projectExecutionResultShape(catalog, capability, executable, responseSet); | |
| 1077 | 1386 | break; |
| 1078 | 1387 | case "graphql": |
| 1079 | 1388 | callShapeId = projectGraphqlCallShape(catalog, capability, executable); |
| 1080 | − resultShapeId = executable.resultShapeId; | |
| 1389 | + resultShapeId = projectExecutionResultShape(catalog, capability, executable, responseSet); | |
| 1081 | 1390 | break; |
| 1082 | 1391 | case "mcp": |
| 1083 | 1392 | callShapeId = projectMcpCallShape(catalog, capability, executable); |
| 1084 | − resultShapeId = executable.outputShapeId; | |
| 1393 | + resultShapeId = projectExecutionResultShape(catalog, capability, executable, responseSet); | |
| 1085 | 1394 | break; |
| 1086 | 1395 | } |
| 1087 | 1396 | |
| @@ -65,15 +65,11 @@ const readSourceTransport = (source: Source): "auto" | "streamable-http" | "sse" · 64 unmodified lines | ||
| 65 | 65 | return undefined; |
| 66 | 66 | }; |
| 67 | 67 | |
| 68 | −const summarizeHttpBody = (body: unknown): string => { | |
| 69 | − if (typeof body === "string") { | |
| 70 | − return body.slice(0, 400); | |
| 71 | − } | |
| 72 | − try { | |
| 73 | − return JSON.stringify(body).slice(0, 400); | |
| 74 | − } catch { | |
| 75 | − return String(body); | |
| 76 | − } | |
| 68 | +type ExecutionEnvelope = { | |
| 69 | + data: unknown; | |
| 70 | + error: unknown; | |
| 71 | + headers: Record<string, string>; | |
| 72 | + status: number | null; | |
| 77 | 73 | }; |
| 78 | 74 | |
| 79 | 75 | const decodeFetchBody = async (response: Response): Promise<unknown> => { |
| @@ -87,6 +83,16 @@ const decodeFetchBody = async (response: Response): Promise<unknown> => { · 7 unmodified lines | ||
| 87 | 83 | return response.text(); |
| 88 | 84 | }; |
| 89 | 85 | |
| 86 | +const responseHeadersRecord = (response: Response): Record<string, string> => { | |
| 87 | + const headers: Record<string, string> = {}; | |
| 88 | + response.headers.forEach((value, key) => { | |
| 89 | + headers[key] = value; | |
| 90 | + }); | |
| 91 | + return headers; | |
| 92 | +}; | |
| 93 | + | |
| 94 | +const executionEnvelope = (input: ExecutionEnvelope): ExecutionEnvelope => input; | |
| 95 | + | |
| 90 | 96 | const parameterById = (catalog: CatalogV1, parameterId: string): ParameterSymbol | undefined => { |
| 91 | 97 | const symbol = catalog.symbols[parameterId]; |
| 92 | 98 | return symbol?.kind === "parameter" ? symbol : undefined; |
| @@ -340,11 +346,12 @@ const executeHttp = (input: { · 247 unmodified lines | ||
| 340 | 346 | ...(body !== undefined ? { body } : {}), |
| 341 | 347 | }); |
| 342 | 348 | const responseBody = await decodeFetchBody(response); |
| 343 | − if (!response.ok) { | |
| 344 | − throw new Error(`HTTP ${response.status}: ${summarizeHttpBody(responseBody)}`); | |
| 345 | − } | |
| 346 | − | |
| 347 | − return responseBody; | |
| 349 | + return executionEnvelope({ | |
| 350 | + data: response.ok ? responseBody : null, | |
| 351 | + error: response.ok ? null : responseBody, | |
| 352 | + headers: responseHeadersRecord(response), | |
| 353 | + status: response.status, | |
| 354 | + }); | |
| 348 | 355 | }, |
| 349 | 356 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), |
| 350 | 357 | }); |
| @@ -394,7 +401,9 @@ const executeGraphql = (input: { · 43 unmodified lines | ||
| 394 | 401 | Effect.tryPromise({ |
| 395 | 402 | try: async () => { |
| 396 | 403 | const argsRecord = asObject(input.args); |
| 397 | − const native = input.executable.native?.[0]?.value as Record<string, unknown> | undefined; | |
| 404 | + const native = input.executable.native | |
| 405 | + ?.find((blob) => blob.kind === "graphql_provider_data") | |
| 406 | + ?.value as Record<string, unknown> | undefined; | |
| 398 | 407 | const requestHeaders = { |
| 399 | 408 | ...readSourceHeaders(input.source), |
| 400 | 409 | ...input.auth.headers, |
| @@ -430,14 +439,19 @@ const executeGraphql = (input: { · 29 unmodified lines | ||
| 430 | 439 | }), |
| 431 | 440 | }); |
| 432 | 441 | const body = await decodeFetchBody(response); |
| 433 | − if (!response.ok) { | |
| 434 | − throw new Error(`GraphQL HTTP ${response.status}: ${summarizeHttpBody(body)}`); | |
| 435 | − } | |
| 436 | − return body; | |
| 442 | + const bodyRecord = asObject(body); | |
| 443 | + const errors = Array.isArray(bodyRecord.errors) ? bodyRecord.errors : []; | |
| 444 | + return executionEnvelope({ | |
| 445 | + data: body, | |
| 446 | + error: errors.length > 0 ? errors : (response.status >= 400 ? body : null), | |
| 447 | + headers: responseHeadersRecord(response), | |
| 448 | + status: response.status, | |
| 449 | + }); | |
| 437 | 450 | } |
| 438 | 451 | |
| 439 | 452 | const operationDocument = asString(native.operationDocument)!; |
| 440 | 453 | const operationName = asString(native.operationName) ?? undefined; |
| 454 | + const fieldName = asString(native.fieldName) ?? input.executable.rootField; | |
| 441 | 455 | const variables = graphqlArgsPayload({ |
| 442 | 456 | catalog: input.catalog, |
| 443 | 457 | executable: input.executable, |
| @@ -468,18 +482,14 @@ const executeGraphql = (input: { · 24 unmodified lines | ||
| 468 | 482 | }), |
| 469 | 483 | }); |
| 470 | 484 | const body = asObject(await decodeFetchBody(response)); |
| 471 | − if (!response.ok) { | |
| 472 | − throw new Error(`GraphQL HTTP ${response.status}: ${summarizeHttpBody(body)}`); | |
| 473 | − } | |
| 474 | − | |
| 475 | 485 | const errors = Array.isArray(body.errors) ? body.errors : []; |
| 476 | 486 | const data = asObject(body.data); |
| 477 | − const fieldName = asString(native.fieldName) ?? input.executable.rootField; | |
| 478 | − return { | |
| 487 | + return executionEnvelope({ | |
| 479 | 488 | data: data[fieldName] ?? null, |
| 480 | − errors, | |
| 481 | − isError: errors.length > 0, | |
| 482 | − }; | |
| 489 | + error: errors.length > 0 ? errors : (response.status >= 400 ? body : null), | |
| 490 | + headers: responseHeadersRecord(response), | |
| 491 | + status: response.status, | |
| 492 | + }); | |
| 483 | 493 | }, |
| 484 | 494 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), |
| 485 | 495 | }); |
| @@ -572,7 +582,15 @@ const executeMcp = (input: { · 86 unmodified lines | ||
| 572 | 582 | } |
| 573 | 583 | : undefined; |
| 574 | 584 | |
| 575 | − return await definition.execute(asObject(payload), executionContext); | |
| 585 | + const result = await definition.execute(asObject(payload), executionContext); | |
| 586 | + const resultRecord = asObject(result); | |
| 587 | + const isError = resultRecord.isError === true; | |
| 588 | + return executionEnvelope({ | |
| 589 | + data: isError ? null : (result ?? null), | |
| 590 | + error: isError ? result : null, | |
| 591 | + headers: {}, | |
| 592 | + status: null, | |
| 593 | + }); | |
| 576 | 594 | }, |
| 577 | 595 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), |
| 578 | 596 | }); |
| @@ -109,8 +109,11 @@ describe("local-source-artifacts", () => { · 108 unmodified lines | ||
| 109 | 109 | const persistedArtifact = JSON.parse(readFileSync(path, "utf8")); |
| 110 | 110 | const persistedDocument = Object.values(persistedArtifact.snapshot.catalog.documents)[0] as { |
| 111 | 111 | native?: ReadonlyArray<{ kind?: string }>; |
| 112 | + provenance?: unknown; | |
| 112 | 113 | }; |
| 113 | 114 | expect(persistedDocument.native?.some((blob) => blob.kind === "source_document") ?? false).toBe(false); |
| 115 | + expect(persistedDocument.provenance).toBeUndefined(); | |
| 116 | + expect(Object.keys(persistedArtifact.snapshot.catalog.diagnostics)).toHaveLength(0); | |
| 114 | 117 | |
| 115 | 118 | const decoded = yield* readLocalSourceArtifact({ |
| 116 | 119 | context, |
| @@ -120,6 +123,7 @@ describe("local-source-artifacts", () => { · 3 unmodified lines | ||
| 120 | 123 | expect(decoded?.snapshot.import.adapterKey).toBe("openapi"); |
| 121 | 124 | expect(decoded?.sourceId).toBe("src_test"); |
| 122 | 125 | expect((Object.values(decoded?.snapshot.catalog.documents ?? {})[0] as { native?: Array<{ value?: unknown }> })?.native?.[0]?.value).toBe('{"openapi":"3.1.0"}'); |
| 126 | + expect((Object.values(decoded?.snapshot.catalog.documents ?? {})[0] as { provenance?: unknown }).provenance).toBeUndefined(); | |
| 123 | 127 | }).pipe(Effect.provide(NodeFileSystem.layer)), |
| 124 | 128 | ); |
| 125 | 129 | |
| @@ -16,6 +16,7 @@ import * as Schema from "effect/Schema"; · 15 unmodified lines | ||
| 16 | 16 | import { |
| 17 | 17 | CatalogSnapshotV1Schema, |
| 18 | 18 | type NativeBlob, |
| 19 | + type ShapeNode, | |
| 19 | 20 | type SourceDocument, |
| 20 | 21 | } from "../ir/model"; |
| 21 | 22 | import type { SourceCatalogSyncResult } from "./source-catalog-support"; |
| @@ -57,6 +58,10 @@ const mapFileSystemError = (path: string, action: string) => (cause: unknown) => · 35 unmodified lines | ||
| 57 | 58 | const mutableRecord = <K extends string, V>(value: Readonly<Record<K, V>>): Record<K, V> => |
| 58 | 59 | value as Record<K, V>; |
| 59 | 60 | |
| 61 | +const clearProvenance = (value: unknown): void => { | |
| 62 | + (value as { provenance: Array<unknown> }).provenance = []; | |
| 63 | +}; | |
| 64 | + | |
| 60 | 65 | const localSourceArtifactPath = (input: { |
| 61 | 66 | context: ResolvedLocalWorkspaceContext; |
| 62 | 67 | sourceId: string; |
| @@ -154,6 +159,109 @@ const hydrateArtifactSourceDocuments = (input: { · 91 unmodified lines | ||
| 154 | 159 | return nextArtifact; |
| 155 | 160 | }; |
| 156 | 161 | |
| 162 | +const stripShapeFieldDocs = (node: ShapeNode): void => { | |
| 163 | + switch (node.type) { | |
| 164 | + case "object": | |
| 165 | + case "graphqlInterface": | |
| 166 | + for (const field of Object.values(node.fields)) { | |
| 167 | + delete (field as { docs?: unknown }).docs; | |
| 168 | + } | |
| 169 | + return; | |
| 170 | + default: | |
| 171 | + return; | |
| 172 | + } | |
| 173 | +}; | |
| 174 | + | |
| 175 | +const stripRuntimeMetadata = (artifact: LocalSourceArtifact): LocalSourceArtifact => { | |
| 176 | + const nextArtifact = structuredClone(artifact); | |
| 177 | + | |
| 178 | + for (const document of Object.values(nextArtifact.snapshot.catalog.documents)) { | |
| 179 | + delete (document as { provenance?: unknown }).provenance; | |
| 180 | + delete (document as { docs?: unknown }).docs; | |
| 181 | + delete (document as { native?: unknown }).native; | |
| 182 | + } | |
| 183 | + | |
| 184 | + for (const resource of Object.values(nextArtifact.snapshot.catalog.resources)) { | |
| 185 | + clearProvenance(resource as unknown); | |
| 186 | + delete (resource as { docs?: unknown }).docs; | |
| 187 | + delete (resource as { native?: unknown }).native; | |
| 188 | + } | |
| 189 | + | |
| 190 | + for (const scope of Object.values(nextArtifact.snapshot.catalog.scopes)) { | |
| 191 | + clearProvenance(scope as unknown); | |
| 192 | + delete (scope as { docs?: unknown }).docs; | |
| 193 | + delete (scope as { native?: unknown }).native; | |
| 194 | + } | |
| 195 | + | |
| 196 | + for (const capability of Object.values(nextArtifact.snapshot.catalog.capabilities)) { | |
| 197 | + clearProvenance(capability as unknown); | |
| 198 | + delete (capability as { native?: unknown }).native; | |
| 199 | + delete (capability as { exampleIds?: unknown }).exampleIds; | |
| 200 | + } | |
| 201 | + | |
| 202 | + for (const executable of Object.values(nextArtifact.snapshot.catalog.executables)) { | |
| 203 | + clearProvenance(executable as unknown); | |
| 204 | + if (Array.isArray((executable as { native?: NativeBlob[] }).native)) { | |
| 205 | + const keptNative = ((executable as { native?: NativeBlob[] }).native ?? []).filter((blob) => | |
| 206 | + blob.kind === "google_discovery_provider_data" | |
| 207 | + ); | |
| 208 | + if (keptNative.length > 0) { | |
| 209 | + (executable as { native?: NativeBlob[] }).native = keptNative; | |
| 210 | + } else { | |
| 211 | + delete (executable as { native?: unknown }).native; | |
| 212 | + } | |
| 213 | + } | |
| 214 | + } | |
| 215 | + | |
| 216 | + for (const responseSet of Object.values(nextArtifact.snapshot.catalog.responseSets)) { | |
| 217 | + clearProvenance(responseSet as unknown); | |
| 218 | + delete (responseSet as { native?: unknown }).native; | |
| 219 | + } | |
| 220 | + | |
| 221 | + for (const diagnostic of Object.values(nextArtifact.snapshot.catalog.diagnostics)) { | |
| 222 | + delete (diagnostic as { provenance?: unknown }).provenance; | |
| 223 | + } | |
| 224 | + | |
| 225 | + const mutableSymbols = mutableRecord(nextArtifact.snapshot.catalog.symbols); | |
| 226 | + for (const [symbolId, symbol] of Object.entries(mutableSymbols)) { | |
| 227 | + if (symbol.kind === "example") { | |
| 228 | + delete mutableSymbols[symbolId as keyof typeof mutableSymbols]; | |
| 229 | + continue; | |
| 230 | + } | |
| 231 | + | |
| 232 | + clearProvenance(symbol as unknown); | |
| 233 | + delete (symbol as { docs?: unknown }).docs; | |
| 234 | + delete (symbol as { diagnosticIds?: unknown }).diagnosticIds; | |
| 235 | + delete (symbol as { native?: unknown }).native; | |
| 236 | + delete (symbol as { deprecated?: unknown }).deprecated; | |
| 237 | + | |
| 238 | + switch (symbol.kind) { | |
| 239 | + case "shape": | |
| 240 | + stripShapeFieldDocs(symbol.node); | |
| 241 | + break; | |
| 242 | + case "parameter": | |
| 243 | + case "header": | |
| 244 | + case "response": | |
| 245 | + delete (symbol as { exampleIds?: unknown }).exampleIds; | |
| 246 | + if ("content" in symbol && Array.isArray(symbol.content)) { | |
| 247 | + for (const content of symbol.content) { | |
| 248 | + delete (content as { exampleIds?: unknown }).exampleIds; | |
| 249 | + } | |
| 250 | + } | |
| 251 | + break; | |
| 252 | + case "requestBody": | |
| 253 | + for (const content of symbol.contents) { | |
| 254 | + delete (content as { exampleIds?: unknown }).exampleIds; | |
| 255 | + } | |
| 256 | + break; | |
| 257 | + } | |
| 258 | + } | |
| 259 | + | |
| 260 | + mutableRecord(nextArtifact.snapshot.catalog).diagnostics = {}; | |
| 261 | + | |
| 262 | + return nextArtifact; | |
| 263 | +}; | |
| 264 | + | |
| 157 | 265 | const snapshotHash = (snapshot: SourceCatalogSyncResult["snapshot"]): string => |
| 158 | 266 | contentHash(JSON.stringify(snapshot)); |
| 159 | 267 | |
| @@ -281,6 +389,7 @@ export const writeLocalSourceArtifact = (input: { · 121 unmodified lines | ||
| 281 | 389 | const legacyPath = legacyLocalSourceArtifactPath(input); |
| 282 | 390 | const sourceDocumentDirectory = localSourceDocumentDirectory(input); |
| 283 | 391 | const split = splitArtifactSourceDocuments(input.artifact); |
| 392 | + const runtimeArtifact = stripRuntimeMetadata(split.artifact); | |
| 284 | 393 | yield* fs.makeDirectory(directory, { recursive: true }).pipe( |
| 285 | 394 | Effect.mapError(mapFileSystemError(directory, "create source artifact directory")), |
| 286 | 395 | ); |
| @@ -302,7 +411,7 @@ export const writeLocalSourceArtifact = (input: { · 15 unmodified lines | ||
| 302 | 411 | ); |
| 303 | 412 | } |
| 304 | 413 | } |
| 305 | − yield* fs.writeFileString(path, `${JSON.stringify(split.artifact)}\n`).pipe( | |
| 414 | + yield* fs.writeFileString(path, `${JSON.stringify(runtimeArtifact)}\n`).pipe( | |
| 306 | 415 | Effect.mapError(mapFileSystemError(path, "write source artifact")), |
| 307 | 416 | ); |
| 308 | 417 | const legacyExists = yield* fs.exists(legacyPath).pipe( |
| @@ -708,7 +708,9 @@ describe("source-catalog-snapshot", () => { · 707 unmodified lines | ||
| 708 | 708 | expect(executable.operationType).toBe("query"); |
| 709 | 709 | expect(executable.rootField).toBe("viewer"); |
| 710 | 710 | expect(executable.selectionMode).toBe("fixed"); |
| 711 | − expect(executable.native).toBeUndefined(); | |
| 711 | + expect(executable.native).toMatchObject([{ | |
| 712 | + kind: "graphql_provider_data", | |
| 713 | + }]); | |
| 712 | 714 | }); |
| 713 | 715 | |
| 714 | 716 | it("projects MCP metadata into capability semantics", () => { |
| @@ -2009,6 +2009,12 @@ const createGraphqlCapability = (input: { · 2008 unmodified lines | ||
| 2009 | 2009 | responseSetId, |
| 2010 | 2010 | synthetic: false, |
| 2011 | 2011 | provenance: provenanceFor(input.documentId, `#/graphql/${input.operation.providerData.toolId}/executable`), |
| 2012 | + native: [nativeBlob({ | |
| 2013 | + source: input.source, | |
| 2014 | + kind: "graphql_provider_data", | |
| 2015 | + pointer: `#/graphql/${input.operation.providerData.toolId}/providerData`, | |
| 2016 | + value: input.operation.providerData, | |
| 2017 | + })], | |
| 2012 | 2018 | } satisfies GraphQLExecutable; |
| 2013 | 2019 | |
| 2014 | 2020 | const effect = input.operation.effect; |