From 3549220acc8ba301c87bb4d7085b08c25059aaca Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 25 Jan 2026 06:09:08 -0700 Subject: [PATCH 01/13] feat(client): display tool annotation badges in Tools tab Add visual badges to show tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) when a tool is selected. Badges appear below the tool description with color-coded styling: - Read-only: blue - Destructive: red - Idempotent: gray outline - Open-world: purple Co-Authored-By: Claude Opus 4.5 --- client/src/components/ToolsTab.tsx | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index d872e629..19d8fa0c 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -24,6 +24,7 @@ import { CompatibilityCallToolResult, ListToolsResult, Tool, + ToolAnnotations, } from "@modelcontextprotocol/sdk/types.js"; import { Loader2, @@ -56,6 +57,63 @@ import { const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } => typeof (tool as { _meta?: unknown })._meta !== "undefined"; +// Type guard to safely detect the optional annotations field +const hasAnnotations = ( + tool: Tool, +): tool is Tool & { annotations: ToolAnnotations } => + typeof (tool as { annotations?: unknown }).annotations !== "undefined" && + (tool as { annotations?: unknown }).annotations !== null; + +// Helper to render annotation badges +const AnnotationBadges = ({ + annotations, +}: { + annotations: ToolAnnotations; +}) => { + const badges: { + label: string; + variant: "default" | "destructive" | "secondary" | "outline"; + }[] = []; + + if (annotations.readOnlyHint === true) { + badges.push({ label: "Read-only", variant: "secondary" }); + } + if (annotations.destructiveHint === true) { + badges.push({ label: "Destructive", variant: "destructive" }); + } + if (annotations.idempotentHint === true) { + badges.push({ label: "Idempotent", variant: "outline" }); + } + if (annotations.openWorldHint === true) { + badges.push({ label: "Open-world", variant: "default" }); + } + + if (badges.length === 0) return null; + + return ( +
+ {badges.map(({ label, variant }) => ( + + {label} + + ))} +
+ ); +}; + const ToolsTab = ({ tools, listTools, @@ -213,6 +271,9 @@ const ToolsTab = ({

{selectedTool.description}

+ {hasAnnotations(selectedTool) && ( + + )} {Object.entries(selectedTool.inputSchema.properties ?? []).map( ([key, value]) => { // First resolve any $ref references From dad17c6cc246fddf6d3fb8578f5088e57b930e58 Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 25 Jan 2026 06:57:56 -0700 Subject: [PATCH 02/13] feat(client): show all tool annotations with explicit/implied states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display all 4 tool annotation badges (Read-only, Destructive, Idempotent, Open-world) for every tool, showing: - ✓/✗ prefix to indicate true/false value - Green badge for true, gray for false - Solid border for explicitly set values - Dashed border + dimmed for implied defaults - Tooltip explaining if value is explicit or implied Follows MCP spec defaults: - readOnlyHint: false - destructiveHint: true - idempotentHint: false - openWorldHint: true Co-Authored-By: Claude Opus 4.5 --- client/src/components/ToolsTab.tsx | 88 +++++++++++++++++++----------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 19d8fa0c..659e3180 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -65,49 +65,69 @@ const hasAnnotations = ( (tool as { annotations?: unknown }).annotations !== null; // Helper to render annotation badges +// Shows all 4 annotation values with their state (true/false/implied default) const AnnotationBadges = ({ annotations, }: { - annotations: ToolAnnotations; + annotations: ToolAnnotations | undefined; }) => { - const badges: { - label: string; - variant: "default" | "destructive" | "secondary" | "outline"; - }[] = []; + // Spec defaults: readOnlyHint=false, destructiveHint=true, idempotentHint=false, openWorldHint=true + const getValueAndImplied = ( + value: boolean | undefined, + defaultValue: boolean, + ): { value: boolean; implied: boolean } => ({ + value: value ?? defaultValue, + implied: value === undefined, + }); - if (annotations.readOnlyHint === true) { - badges.push({ label: "Read-only", variant: "secondary" }); - } - if (annotations.destructiveHint === true) { - badges.push({ label: "Destructive", variant: "destructive" }); - } - if (annotations.idempotentHint === true) { - badges.push({ label: "Idempotent", variant: "outline" }); - } - if (annotations.openWorldHint === true) { - badges.push({ label: "Open-world", variant: "default" }); - } + const readOnly = getValueAndImplied(annotations?.readOnlyHint, false); + const destructive = getValueAndImplied(annotations?.destructiveHint, true); + const idempotent = getValueAndImplied(annotations?.idempotentHint, false); + const openWorld = getValueAndImplied(annotations?.openWorldHint, true); - if (badges.length === 0) return null; + const badges = [ + { + label: "Read-only", + value: readOnly.value, + implied: readOnly.implied, + }, + { + label: "Destructive", + value: destructive.value, + implied: destructive.implied, + }, + { + label: "Idempotent", + value: idempotent.value, + implied: idempotent.implied, + }, + { + label: "Open-world", + value: openWorld.value, + implied: openWorld.implied, + }, + ]; return (
- {badges.map(({ label, variant }) => ( + {badges.map(({ label, value, implied }) => ( - {label} + {value ? "✓" : "✗"} {label} ))}
@@ -271,9 +291,13 @@ const ToolsTab = ({

{selectedTool.description}

- {hasAnnotations(selectedTool) && ( - - )} + {Object.entries(selectedTool.inputSchema.properties ?? []).map( ([key, value]) => { // First resolve any $ref references From 1cadda06c04bf33559ca5662eda8b5ed745dd65f Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 25 Jan 2026 07:03:21 -0700 Subject: [PATCH 03/13] feat(client): add MCP spec descriptions to annotation tooltips Enhance tooltips to include descriptions from the MCP specification: - Read-only: Tool does not modify its environment - Destructive: Tool may perform destructive updates (delete/overwrite data) - Idempotent: Calling repeatedly with same args has no additional effect - Open-world: Tool may interact with external entities beyond its local environment Co-Authored-By: Claude Opus 4.5 --- client/src/components/ToolsTab.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 659e3180..2128d599 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -85,39 +85,42 @@ const AnnotationBadges = ({ const idempotent = getValueAndImplied(annotations?.idempotentHint, false); const openWorld = getValueAndImplied(annotations?.openWorldHint, true); + // Descriptions from MCP spec const badges = [ { label: "Read-only", value: readOnly.value, implied: readOnly.implied, + description: "Tool does not modify its environment", }, { label: "Destructive", value: destructive.value, implied: destructive.implied, + description: + "Tool may perform destructive updates (delete/overwrite data)", }, { label: "Idempotent", value: idempotent.value, implied: idempotent.implied, + description: "Calling repeatedly with same args has no additional effect", }, { label: "Open-world", value: openWorld.value, implied: openWorld.implied, + description: + "Tool may interact with external entities beyond its local environment", }, ]; return (
- {badges.map(({ label, value, implied }) => ( + {badges.map(({ label, value, implied, description }) => ( Date: Wed, 4 Feb 2026 12:37:55 +0000 Subject: [PATCH 04/13] fix: include LICENSE file in published npm packages Add LICENSE file to each workspace (cli, client, server) and update their package.json files to include LICENSE in the published packages. Fixes #1036 Co-authored-by: Ola Hungerford --- cli/LICENSE | 216 ++++++++++++++++++++++++++++++++++++++++++++ cli/package.json | 3 +- client/LICENSE | 216 ++++++++++++++++++++++++++++++++++++++++++++ client/package.json | 3 +- server/LICENSE | 216 ++++++++++++++++++++++++++++++++++++++++++++ server/package.json | 3 +- 6 files changed, 654 insertions(+), 3 deletions(-) create mode 100644 cli/LICENSE create mode 100644 client/LICENSE create mode 100644 server/LICENSE diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 00000000..4a939857 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/cli/package.json b/cli/package.json index ce521bee..e45c72a4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -12,7 +12,8 @@ "mcp-inspector-cli": "build/cli.js" }, "files": [ - "build" + "build", + "LICENSE" ], "scripts": { "build": "tsc", diff --git a/client/LICENSE b/client/LICENSE new file mode 100644 index 00000000..4a939857 --- /dev/null +++ b/client/LICENSE @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/client/package.json b/client/package.json index 1feec212..c5b10133 100644 --- a/client/package.json +++ b/client/package.json @@ -12,7 +12,8 @@ }, "files": [ "bin", - "dist" + "dist", + "LICENSE" ], "scripts": { "dev": "vite --port 6274", diff --git a/server/LICENSE b/server/LICENSE new file mode 100644 index 00000000..4a939857 --- /dev/null +++ b/server/LICENSE @@ -0,0 +1,216 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + +MIT License + +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/server/package.json b/server/package.json index 622b889d..10506f35 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "mcp-inspector-server": "build/index.js" }, "files": [ - "build" + "build", + "LICENSE" ], "scripts": { "build": "tsc", From 43426f3c8eb75565ed039591daaa5186b5586c8f Mon Sep 17 00:00:00 2001 From: olaservo Date: Sat, 7 Feb 2026 10:36:20 -0700 Subject: [PATCH 05/13] feat(client): use consistent slate styling for annotation badges Replace value-based green/gray badge coloring with uniform slate colors, relying on checkmark/cross icons and opacity to convey state. Co-Authored-By: Claude Opus 4.6 --- client/src/components/ToolsTab.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 2128d599..a805de0d 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -123,11 +123,9 @@ const AnnotationBadges = ({ title={`${description}\n\nValue: ${value ? "Yes" : "No"} (${implied ? "implied default" : "explicitly set"})`} className={cn( "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border", + "bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600", implied && "border-dashed opacity-60", !implied && "border-solid", - value - ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-300 dark:border-green-700" - : "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 border-gray-300 dark:border-gray-600", )} > {value ? "✓" : "✗"} {label} From 00791446a8088996139162b64aa19582ed9a840a Mon Sep 17 00:00:00 2001 From: Ruben Casas Date: Sun, 8 Feb 2026 00:16:35 +0000 Subject: [PATCH 06/13] fix: add missing tool result to AppsRenderer --- client/src/components/AppRenderer.tsx | 68 +++- .../components/__tests__/AppRenderer.test.tsx | 303 ++++++++++++++++-- 2 files changed, 340 insertions(+), 31 deletions(-) diff --git a/client/src/components/AppRenderer.tsx b/client/src/components/AppRenderer.tsx index e0c25980..7ac56a16 100644 --- a/client/src/components/AppRenderer.tsx +++ b/client/src/components/AppRenderer.tsx @@ -1,8 +1,10 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Tool, ContentBlock, + CallToolResult, + CallToolResultSchema, ServerNotification, LoggingMessageNotificationParams, } from "@modelcontextprotocol/sdk/types.js"; @@ -35,6 +37,8 @@ const AppRenderer = ({ onNotification, }: AppRendererProps) => { const [error, setError] = useState(null); + const [toolResult, setToolResult] = useState(); + const latestRunIdRef = useRef(0); const { toast } = useToast(); const hostContext: McpUiHostContext = useMemo( @@ -85,6 +89,67 @@ const AppRenderer = ({ } }; + useEffect(() => { + if (!mcpClient) { + setToolResult(undefined); + return; + } + + const runId = ++latestRunIdRef.current; + const abortController = new AbortController(); + setToolResult(undefined); + + const runTool = async () => { + try { + const result = await mcpClient.request( + { + method: "tools/call", + params: { + name: tool.name, + arguments: toolInput ?? {}, + }, + }, + CallToolResultSchema, + { signal: abortController.signal }, + ); + + if ( + abortController.signal.aborted || + runId !== latestRunIdRef.current + ) { + return; + } + + setToolResult(result); + } catch (runError) { + if ( + abortController.signal.aborted || + runId !== latestRunIdRef.current + ) { + return; + } + + const message = + runError instanceof Error ? runError.message : String(runError); + setToolResult({ + content: [ + { + type: "text", + text: message, + }, + ], + isError: true, + }); + } + }; + + void runTool(); + + return () => { + abortController.abort(); + }; + }, [mcpClient, tool.name, toolInput]); + if (!mcpClient) { return ( @@ -115,6 +180,7 @@ const AppRenderer = ({ toolName={tool.name} hostContext={hostContext} toolInput={toolInput} + toolResult={toolResult} sandbox={{ url: new URL(sandboxPath, window.location.origin), }} diff --git a/client/src/components/__tests__/AppRenderer.test.tsx b/client/src/components/__tests__/AppRenderer.test.tsx index beb57e1a..723e4f98 100644 --- a/client/src/components/__tests__/AppRenderer.test.tsx +++ b/client/src/components/__tests__/AppRenderer.test.tsx @@ -1,12 +1,30 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, jest, beforeEach } from "@jest/globals"; import AppRenderer from "../AppRenderer"; -import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { RequestHandlerExtra } from "@mcp-ui/client"; import { McpUiMessageResult } from "@modelcontextprotocol/ext-apps"; +type BridgeEvent = { + type: "sendToolInput" | "sendToolResult"; + toolName: string; + payload: unknown; +}; + +type MockMcpUiRendererProps = { + toolName: string; + toolInput?: Record; + toolResult?: CallToolResult; + onMessage?: ( + params: { role: "user"; content: { type: "text"; text: string }[] }, + extra: RequestHandlerExtra, + ) => Promise; +}; + +const mockBridgeEvents: BridgeEvent[] = []; + // Mock the ext-apps module jest.mock("@modelcontextprotocol/ext-apps/app-bridge", () => ({ getToolUiResourceUri: (tool: Tool) => { @@ -24,34 +42,84 @@ jest.mock("@/lib/hooks/useToast", () => ({ }), })); -// Mock @mcp-ui/client -jest.mock("@mcp-ui/client", () => ({ - AppRenderer: ({ - toolName, - onMessage, - }: { - toolName: string; - onMessage?: ( - params: { role: "user"; content: { type: "text"; text: string }[] }, - extra: RequestHandlerExtra, - ) => Promise; - }) => ( -
-
{toolName}
- -
- ), -})); + }, [isInitialized, toolInput, toolName]); + + React.useEffect(() => { + if (isInitialized && toolResult) { + mockBridgeEvents.push({ + type: "sendToolResult", + toolName, + payload: toolResult, + }); + } + }, [isInitialized, toolResult, toolName]); + + return ( +
+
{toolName}
+
+ {JSON.stringify(toolResult ?? null)} +
+ + +
+ ); + }, + }; +}); + +const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; describe("AppRenderer", () => { const mockTool: Tool = { @@ -69,7 +137,12 @@ describe("AppRenderer", () => { } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; const mockMcpClient = { - request: jest.fn(), + request: jest.fn( + () => + new Promise(() => { + // Intentionally unresolved for baseline rendering tests. + }), + ), getServerCapabilities: jest.fn().mockReturnValue({}), setNotificationHandler: jest.fn(), } as unknown as Client; @@ -82,6 +155,7 @@ describe("AppRenderer", () => { beforeEach(() => { jest.clearAllMocks(); + mockBridgeEvents.length = 0; }); it("should display waiting state when mcpClient is null", () => { @@ -112,4 +186,173 @@ describe("AppRenderer", () => { description: "Test message", }); }); + + it("should call tools/call and send tool input/result after app initialization", async () => { + const mockResult: CallToolResult = { + content: [{ type: "text", text: "Budget initialized" }], + }; + const requestMock = jest.fn().mockResolvedValue(mockResult); + const mcpClient = { + ...mockMcpClient, + request: requestMock, + } as unknown as Client; + + render( + , + ); + + await waitFor(() => { + expect(requestMock).toHaveBeenCalledWith( + { + method: "tools/call", + params: { + name: "testApp", + arguments: { monthlyBudget: 2500 }, + }, + }, + expect.any(Object), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + expect(mockBridgeEvents).toHaveLength(0); + + fireEvent.click(screen.getByTestId("initialize-app")); + + await waitFor(() => { + const toolEvents = mockBridgeEvents.filter( + (event) => event.toolName === "testApp", + ); + expect(toolEvents.map((event) => event.type)).toEqual([ + "sendToolInput", + "sendToolResult", + ]); + }); + + const resultEvent = mockBridgeEvents.find( + (event) => + event.toolName === "testApp" && event.type === "sendToolResult", + ); + expect(resultEvent?.payload).toEqual(mockResult); + }); + + it("should send an app-consumable error result when tools/call fails", async () => { + const requestMock = jest + .fn() + .mockRejectedValue(new Error("tool execution failed")); + const mcpClient = { + ...mockMcpClient, + request: requestMock, + } as unknown as Client; + + render( + , + ); + + await waitFor(() => { + expect(requestMock).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(screen.getByTestId("initialize-app")); + + await waitFor(() => { + const resultEvent = mockBridgeEvents.find( + (event) => + event.toolName === "testApp" && event.type === "sendToolResult", + ); + + expect(resultEvent?.payload).toEqual({ + content: [{ type: "text", text: "tool execution failed" }], + isError: true, + }); + }); + }); + + it("should ignore stale tool results after switching app tools", async () => { + const firstResult: CallToolResult = { + content: [{ type: "text", text: "stale result" }], + }; + const secondResult: CallToolResult = { + content: [{ type: "text", text: "fresh result" }], + }; + + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + const requestMock = jest + .fn() + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(secondRequest.promise); + + const mcpClient = { + ...mockMcpClient, + request: requestMock, + } as unknown as Client; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(requestMock).toHaveBeenCalledTimes(1); + }); + + const secondTool = { + ...mockTool, + name: "budgetAllocatorApp", + }; + + rerender( + , + ); + + await waitFor(() => { + expect(requestMock).toHaveBeenCalledTimes(2); + }); + + secondRequest.resolve(secondResult); + await waitFor(() => { + expect(screen.getByTestId("tool-result")).toHaveTextContent( + "fresh result", + ); + }); + + fireEvent.click(screen.getByTestId("initialize-app")); + + await waitFor(() => { + const freshEvents = mockBridgeEvents.filter( + (event) => + event.toolName === "budgetAllocatorApp" && + event.type === "sendToolResult", + ); + expect(freshEvents).toHaveLength(1); + expect(freshEvents[0].payload).toEqual(secondResult); + }); + + firstRequest.resolve(firstResult); + await Promise.resolve(); + await Promise.resolve(); + + const secondToolResultEvents = mockBridgeEvents.filter( + (event) => + event.toolName === "budgetAllocatorApp" && + event.type === "sendToolResult", + ); + + expect(secondToolResultEvents).toHaveLength(1); + expect(secondToolResultEvents[0].payload).toEqual(secondResult); + expect(screen.getByTestId("tool-result")).toHaveTextContent("fresh result"); + }); }); From 41e0e83fac4db8b2baeee19b8542e8f07f145570 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 12:55:44 +0000 Subject: [PATCH 07/13] Bump qs in the npm_and_yarn group across 1 directory Bumps the npm_and_yarn group with 1 update in the / directory: [qs](https://github.com/ljharb/qs). Updates `qs` from 6.14.1 to 6.14.2 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.14.1...v6.14.2) --- updated-dependencies: - dependency-name: qs dependency-version: 6.14.2 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package-lock.json | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b4dbc29..da310ffb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11010,9 +11010,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -14027,24 +14027,6 @@ "tsx": "^4.19.0", "typescript": "^5.6.2" } - }, - "server/node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", - "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } } } } From a31ae3e767bb6d1adaab8edb169700171f1b1f5a Mon Sep 17 00:00:00 2001 From: Ruben Casas Date: Mon, 16 Feb 2026 16:44:33 +0000 Subject: [PATCH 08/13] feat call tool result from tools tab --- client/src/App.tsx | 99 ++++- .../__tests__/App.toolsAppsPrefill.test.tsx | 288 ++++++++++++++ client/src/components/AppRenderer.tsx | 90 ++--- client/src/components/AppsTab.tsx | 219 +++++++++-- client/src/components/ToolsTab.tsx | 2 +- .../components/__tests__/AppRenderer.test.tsx | 188 +++------ .../src/components/__tests__/AppsTab.test.tsx | 361 ++++++++++++++++-- patches/@mcp-ui+client+6.0.0.patch | 226 +++++++++++ 8 files changed, 1181 insertions(+), 292 deletions(-) create mode 100644 client/src/__tests__/App.toolsAppsPrefill.test.tsx create mode 100644 patches/@mcp-ui+client+6.0.0.patch diff --git a/client/src/App.tsx b/client/src/App.tsx index 5a69495b..184f04d9 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -30,6 +30,7 @@ import { hasValidMetaPrefix, isReservedMetaKey, } from "@/utils/metaUtils"; +import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; @@ -101,6 +102,27 @@ import MetadataTab from "./components/MetadataTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; +type PrefilledAppsToolCall = { + id: number; + toolName: string; + params: Record; + result: CompatibilityCallToolResult; +}; + +const hasAppResourceUri = (tool: Tool): boolean => { + return Boolean(getToolUiResourceUri(tool)); +}; + +const cloneToolParams = ( + source: Record, +): Record => { + try { + return structuredClone(source); + } catch { + return { ...source }; + } +}; + const filterReservedMetadata = ( metadata: Record, ): Record => { @@ -137,6 +159,8 @@ const App = () => { const [tasks, setTasks] = useState([]); const [toolResult, setToolResult] = useState(null); + const [prefilledAppsToolCall, setPrefilledAppsToolCall] = + useState(null); const [errors, setErrors] = useState>({ resources: null, prompts: null, @@ -291,6 +315,7 @@ const App = () => { const [nextToolCursor, setNextToolCursor] = useState(); const [nextTaskCursor, setNextTaskCursor] = useState(); const progressTokenRef = useRef(0); + const prefilledAppsToolCallIdRef = useRef(0); const [activeTab, setActiveTab] = useState(() => { const hash = window.location.hash.slice(1); @@ -977,7 +1002,7 @@ const App = () => { params: Record, toolMetadata?: Record, runAsTask?: boolean, - ) => { + ): Promise => { lastToolCallOriginTabRef.current = currentTabRef.current; try { @@ -1045,7 +1070,7 @@ const App = () => { "_meta" in (response as Record) ? ((response as { _meta?: Record })._meta ?? {}) : undefined; - setToolResult({ + let latestToolResult: CompatibilityCallToolResult = { content: [ { type: "text", @@ -1056,7 +1081,8 @@ const App = () => { ...(initialResponseMeta || {}), "io.modelcontextprotocol/related-task": { taskId }, }, - } as CompatibilityCallToolResult); + }; + setToolResult(latestToolResult); // Polling loop let taskCompleted = false; @@ -1093,12 +1119,13 @@ const App = () => { CompatibilityCallToolResultSchema, ); console.log(`Result received for task ${taskId}:`, result); - setToolResult(result as CompatibilityCallToolResult); + latestToolResult = result as CompatibilityCallToolResult; + setToolResult(latestToolResult); // Refresh tasks list to show completed state void listTasks(); } else { - setToolResult({ + latestToolResult = { content: [ { type: "text", @@ -1106,7 +1133,8 @@ const App = () => { }, ], isError: true, - }); + }; + setToolResult(latestToolResult); // Refresh tasks list to show failed/cancelled state void listTasks(); } @@ -1120,7 +1148,7 @@ const App = () => { ? ((response as { _meta?: Record })._meta ?? {}) : undefined; - setToolResult({ + latestToolResult = { content: [ { type: "text", @@ -1131,13 +1159,14 @@ const App = () => { ...(pollingResponseMeta || {}), "io.modelcontextprotocol/related-task": { taskId }, }, - } as CompatibilityCallToolResult); + }; + setToolResult(latestToolResult); // Refresh tasks list to show progress void listTasks(); } } catch (pollingError) { console.error("Error polling task status:", pollingError); - setToolResult({ + latestToolResult = { content: [ { type: "text", @@ -1145,16 +1174,22 @@ const App = () => { }, ], isError: true, - }); + }; + setToolResult(latestToolResult); taskCompleted = true; } } setIsPollingTask(false); + // Clear any validation errors since tool execution completed + setErrors((prev) => ({ ...prev, tools: null })); + return latestToolResult; } else { - setToolResult(response as CompatibilityCallToolResult); + const directResult = response as CompatibilityCallToolResult; + setToolResult(directResult); + // Clear any validation errors since tool execution completed + setErrors((prev) => ({ ...prev, tools: null })); + return directResult; } - // Clear any validation errors since tool execution completed - setErrors((prev) => ({ ...prev, tools: null })); } catch (e) { const toolResult: CompatibilityCallToolResult = { content: [ @@ -1168,6 +1203,7 @@ const App = () => { setToolResult(toolResult); // Clear validation errors - tool execution errors are shown in ToolResults setErrors((prev) => ({ ...prev, tools: null })); + return toolResult; } }; @@ -1508,7 +1544,26 @@ const App = () => { ) => { clearError("tools"); setToolResult(null); - await callTool(name, params, metadata, runAsTask); + const result = await callTool( + name, + params, + metadata, + runAsTask, + ); + const calledTool = tools.find( + (tool) => tool.name === name, + ); + if (calledTool && hasAppResourceUri(calledTool)) { + setPrefilledAppsToolCall({ + id: ++prefilledAppsToolCallIdRef.current, + toolName: name, + params: cloneToolParams(params), + result, + }); + } else { + setPrefilledAppsToolCall(null); + } + return result; }} selectedTool={selectedTool} setSelectedTool={(tool) => { @@ -1552,6 +1607,22 @@ const App = () => { clearError("tools"); listTools(); }} + callTool={async ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => { + clearError("tools"); + setToolResult(null); + return callTool(name, params, metadata, runAsTask); + }} + prefilledToolCall={prefilledAppsToolCall} + onPrefilledToolCallConsumed={(callId) => { + setPrefilledAppsToolCall((prev) => + prev?.id === callId ? null : prev, + ); + }} error={errors.tools} mcpClient={mcpClient} onNotification={(notification) => { diff --git a/client/src/__tests__/App.toolsAppsPrefill.test.tsx b/client/src/__tests__/App.toolsAppsPrefill.test.tsx new file mode 100644 index 00000000..4931800c --- /dev/null +++ b/client/src/__tests__/App.toolsAppsPrefill.test.tsx @@ -0,0 +1,288 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import App from "../App"; +import { useConnection } from "../lib/hooks/useConnection"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +type ToolListEntry = { + name: string; + inputSchema: { + type: "object"; + properties: Record; + }; + _meta: Record; +}; + +let latestAppsTabProps: + | { + tools: ToolListEntry[]; + prefilledToolCall?: { + id: number; + toolName: string; + params: Record; + result: { + content: Array<{ type: string; text: string }>; + }; + } | null; + onPrefilledToolCallConsumed?: (callId: number) => void; + } + | undefined; + +// Mock auth dependencies first +jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: jest.fn(), +})); + +jest.mock("../lib/oauth-state-machine", () => ({ + OAuthStateMachine: jest.fn(), +})); + +jest.mock("../lib/auth", () => ({ + InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ + tokens: jest.fn().mockResolvedValue(null), + clear: jest.fn(), + })), + DebugInspectorOAuthClientProvider: jest.fn(), +})); + +jest.mock("../utils/configUtils", () => ({ + ...jest.requireActual("../utils/configUtils"), + getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), + getMCPProxyAuthToken: jest.fn(() => ({ + token: "", + header: "X-MCP-Proxy-Auth", + })), + getInitialTransportType: jest.fn(() => "stdio"), + getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), + getInitialCommand: jest.fn(() => "mcp-server-everything"), + getInitialArgs: jest.fn(() => ""), + initializeInspectorConfig: jest.fn(() => ({})), + saveInspectorConfig: jest.fn(), +})); + +jest.mock("../lib/hooks/useDraggablePane", () => ({ + useDraggablePane: () => ({ + height: 300, + handleDragStart: jest.fn(), + }), + useDraggableSidebar: () => ({ + width: 320, + isDragging: false, + handleDragStart: jest.fn(), + }), +})); + +jest.mock("../components/Sidebar", () => ({ + __esModule: true, + default: () =>
Sidebar
, +})); + +jest.mock("../components/ResourcesTab", () => ({ + __esModule: true, + default: () =>
ResourcesTab
, +})); + +jest.mock("../components/PromptsTab", () => ({ + __esModule: true, + default: () =>
PromptsTab
, +})); + +jest.mock("../components/TasksTab", () => ({ + __esModule: true, + default: () =>
TasksTab
, +})); + +jest.mock("../components/ConsoleTab", () => ({ + __esModule: true, + default: () =>
ConsoleTab
, +})); + +jest.mock("../components/PingTab", () => ({ + __esModule: true, + default: () =>
PingTab
, +})); + +jest.mock("../components/SamplingTab", () => ({ + __esModule: true, + default: () =>
SamplingTab
, +})); + +jest.mock("../components/RootsTab", () => ({ + __esModule: true, + default: () =>
RootsTab
, +})); + +jest.mock("../components/ElicitationTab", () => ({ + __esModule: true, + default: () =>
ElicitationTab
, +})); + +jest.mock("../components/MetadataTab", () => ({ + __esModule: true, + default: () =>
MetadataTab
, +})); + +jest.mock("../components/AuthDebugger", () => ({ + __esModule: true, + default: () =>
AuthDebugger
, +})); + +jest.mock("../components/HistoryAndNotifications", () => ({ + __esModule: true, + default: () =>
HistoryAndNotifications
, +})); + +jest.mock("../components/ToolsTab", () => ({ + __esModule: true, + default: ({ + listTools, + callTool, + }: { + listTools: () => void; + callTool: ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => Promise; + }) => ( +
+ + +
+ ), +})); + +jest.mock("../components/AppsTab", () => ({ + __esModule: true, + default: (props: typeof latestAppsTabProps) => { + latestAppsTabProps = props; + const prefilled = + props && "prefilledToolCall" in props ? props.prefilledToolCall : null; + const tools = props && "tools" in props ? props.tools : []; + + return ( +
+
{JSON.stringify(tools)}
+
+ {JSON.stringify(prefilled ?? null)} +
+ {prefilled && ( + + )} +
+ ); + }, +})); + +global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); + +jest.mock("../lib/hooks/useConnection", () => ({ + useConnection: jest.fn(), +})); + +describe("App - Tools to Apps prefilled handoff", () => { + const mockUseConnection = jest.mocked(useConnection); + + beforeEach(() => { + jest.clearAllMocks(); + latestAppsTabProps = undefined; + window.location.hash = "#tools"; + }); + + it("passes prefilled call data to AppsTab for tools using _meta['ui/resourceUri']", async () => { + const makeRequest = jest.fn(async (request: { method: string }) => { + if (request.method === "tools/list") { + return { + tools: [ + { + name: "weatherApp", + inputSchema: { + type: "object", + properties: { + city: { type: "string" }, + }, + }, + _meta: { + "ui/resourceUri": "ui://weather-app", + }, + }, + ], + nextCursor: undefined, + }; + } + + if (request.method === "tools/call") { + return { + content: [{ type: "text", text: "weather result" }], + }; + } + + throw new Error(`Unexpected method: ${request.method}`); + }); + + mockUseConnection.mockReturnValue({ + connectionStatus: "connected", + serverCapabilities: { tools: { listChanged: true } }, + serverImplementation: null, + mcpClient: { + request: jest.fn(), + notification: jest.fn(), + close: jest.fn(), + } as unknown as Client, + requestHistory: [], + clearRequestHistory: jest.fn(), + makeRequest, + cancelTask: jest.fn(), + listTasks: jest.fn(), + sendNotification: jest.fn(), + handleCompletion: jest.fn(), + completionsSupported: false, + connect: jest.fn(), + disconnect: jest.fn(), + } as ReturnType); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /mock list tools/i })); + + await waitFor(() => { + const tools = JSON.parse( + screen.getByTestId("apps-tools").textContent || "[]", + ); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe("weatherApp"); + }); + + fireEvent.click(screen.getByRole("button", { name: /mock run app tool/i })); + + await waitFor(() => { + const prefilled = JSON.parse( + screen.getByTestId("apps-prefilled").textContent || "null", + ); + expect(prefilled.toolName).toBe("weatherApp"); + expect(prefilled.params).toEqual({ city: "Lisbon" }); + expect(prefilled.result.content[0].text).toBe("weather result"); + }); + + fireEvent.click(screen.getByRole("button", { name: /consume prefilled/i })); + + await waitFor(() => { + expect(screen.getByTestId("apps-prefilled")).toHaveTextContent("null"); + }); + }); +}); diff --git a/client/src/components/AppRenderer.tsx b/client/src/components/AppRenderer.tsx index 7ac56a16..e25f35c9 100644 --- a/client/src/components/AppRenderer.tsx +++ b/client/src/components/AppRenderer.tsx @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Tool, ContentBlock, + CompatibilityCallToolResult, CallToolResult, CallToolResultSchema, ServerNotification, @@ -26,6 +27,7 @@ interface AppRendererProps { tool: Tool; mcpClient: Client | null; toolInput?: Record; + toolResult?: CompatibilityCallToolResult | null; onNotification?: (notification: ServerNotification) => void; } @@ -34,13 +36,32 @@ const AppRenderer = ({ tool, mcpClient, toolInput, + toolResult, onNotification, }: AppRendererProps) => { const [error, setError] = useState(null); - const [toolResult, setToolResult] = useState(); - const latestRunIdRef = useRef(0); const { toast } = useToast(); + const normalizedToolResult = useMemo(() => { + if (!toolResult) { + return undefined; + } + + if ("content" in toolResult) { + const parsedResult = CallToolResultSchema.safeParse(toolResult); + return parsedResult.success ? parsedResult.data : undefined; + } + + if ("toolResult" in toolResult) { + const parsedResult = CallToolResultSchema.safeParse( + toolResult.toolResult, + ); + return parsedResult.success ? parsedResult.data : undefined; + } + + return undefined; + }, [toolResult]); + const hostContext: McpUiHostContext = useMemo( () => ({ theme: document.documentElement.classList.contains("dark") @@ -89,67 +110,6 @@ const AppRenderer = ({ } }; - useEffect(() => { - if (!mcpClient) { - setToolResult(undefined); - return; - } - - const runId = ++latestRunIdRef.current; - const abortController = new AbortController(); - setToolResult(undefined); - - const runTool = async () => { - try { - const result = await mcpClient.request( - { - method: "tools/call", - params: { - name: tool.name, - arguments: toolInput ?? {}, - }, - }, - CallToolResultSchema, - { signal: abortController.signal }, - ); - - if ( - abortController.signal.aborted || - runId !== latestRunIdRef.current - ) { - return; - } - - setToolResult(result); - } catch (runError) { - if ( - abortController.signal.aborted || - runId !== latestRunIdRef.current - ) { - return; - } - - const message = - runError instanceof Error ? runError.message : String(runError); - setToolResult({ - content: [ - { - type: "text", - text: message, - }, - ], - isError: true, - }); - } - }; - - void runTool(); - - return () => { - abortController.abort(); - }; - }, [mcpClient, tool.name, toolInput]); - if (!mcpClient) { return ( @@ -180,7 +140,7 @@ const AppRenderer = ({ toolName={tool.name} hostContext={hostContext} toolInput={toolInput} - toolResult={toolResult} + toolResult={normalizedToolResult} sandbox={{ url: new URL(sandboxPath, window.location.origin), }} diff --git a/client/src/components/AppsTab.tsx b/client/src/components/AppsTab.tsx index 1943c439..2b6d7535 100644 --- a/client/src/components/AppsTab.tsx +++ b/client/src/components/AppsTab.tsx @@ -6,11 +6,16 @@ import { AlertCircle, X, Play, + Loader2, ChevronRight, Maximize2, Minimize2, } from "lucide-react"; -import { Tool, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { + Tool, + ServerNotification, + CompatibilityCallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"; import AppRenderer from "./AppRenderer"; @@ -40,6 +45,19 @@ interface AppsTabProps { sandboxPath: string; tools: Tool[]; listTools: () => void; + callTool: ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => Promise; + prefilledToolCall?: { + id: number; + toolName: string; + params: Record; + result: CompatibilityCallToolResult; + } | null; + onPrefilledToolCallConsumed?: (callId: number) => void; error: string | null; mcpClient: Client | null; onNotification?: (notification: ServerNotification) => void; @@ -50,10 +68,23 @@ const hasUIMetadata = (tool: Tool): boolean => { return !!getToolUiResourceUri(tool); }; +const cloneToolParams = ( + source: Record, +): Record => { + try { + return structuredClone(source); + } catch { + return { ...source }; + } +}; + const AppsTab = ({ sandboxPath, tools, listTools, + callTool, + prefilledToolCall, + onPrefilledToolCallConsumed, error, mcpClient, onNotification, @@ -62,9 +93,38 @@ const AppsTab = ({ const [selectedTool, setSelectedTool] = useState(null); const [params, setParams] = useState>({}); const [isAppOpen, setIsAppOpen] = useState(false); + const [isOpeningApp, setIsOpeningApp] = useState(false); const [isMaximized, setIsMaximized] = useState(false); const [hasValidationErrors, setHasValidationErrors] = useState(false); + const [submittedParams, setSubmittedParams] = useState< + Record | undefined + >(undefined); + const [submittedToolResult, setSubmittedToolResult] = + useState(null); const formRefs = useRef>({}); + const openAppRunIdRef = useRef(0); + const prefillingParamsRef = useRef | null>(null); + const consumedPrefilledCallIdRef = useRef(null); + + const buildInitialParams = useCallback((tool: Tool) => { + const initialParams = Object.entries(tool.inputSchema.properties ?? []).map( + ([key, value]) => { + const resolvedValue = resolveRef( + value as JsonSchemaType, + tool.inputSchema as JsonSchemaType, + ); + return [ + key, + generateDefaultValue( + resolvedValue, + key, + tool.inputSchema as JsonSchemaType, + ), + ]; + }, + ); + return Object.fromEntries(initialParams); + }, []); // Function to check if any form has validation errors const checkValidationErrors = useCallback(() => { @@ -89,63 +149,145 @@ const AppsTab = ({ if (selectedTool && !filtered.find((t) => t.name === selectedTool.name)) { setSelectedTool(null); setIsAppOpen(false); + setSubmittedParams(undefined); + setSubmittedToolResult(null); } }, [tools, selectedTool]); useEffect(() => { if (selectedTool) { - const initialParams = Object.entries( - selectedTool.inputSchema.properties ?? [], - ).map(([key, value]) => { - // First resolve any $ref references - const resolvedValue = resolveRef( - value as JsonSchemaType, - selectedTool.inputSchema as JsonSchemaType, - ); - return [ - key, - generateDefaultValue( - resolvedValue, - key, - selectedTool.inputSchema as JsonSchemaType, - ), - ]; - }); - setParams(Object.fromEntries(initialParams)); + const prefillingParams = prefillingParamsRef.current; + if (prefillingParams) { + setParams(prefillingParams); + prefillingParamsRef.current = null; + } else { + setParams(buildInitialParams(selectedTool)); + } setHasValidationErrors(false); formRefs.current = {}; } else { setParams({}); setIsAppOpen(false); + setSubmittedParams(undefined); + setSubmittedToolResult(null); } - }, [selectedTool]); + }, [buildInitialParams, selectedTool]); + + useEffect(() => { + if (!prefilledToolCall) { + return; + } + + if (consumedPrefilledCallIdRef.current === prefilledToolCall.id) { + return; + } + + const matchingTool = appTools.find( + (tool) => tool.name === prefilledToolCall.toolName, + ); + if (!matchingTool) { + return; + } + + const hydratedParams = cloneToolParams(prefilledToolCall.params); + + openAppRunIdRef.current += 1; + setIsOpeningApp(false); + prefillingParamsRef.current = hydratedParams; + setSelectedTool(matchingTool); + setSubmittedParams(hydratedParams); + setSubmittedToolResult(prefilledToolCall.result); + setIsAppOpen(true); + setIsMaximized(false); + consumedPrefilledCallIdRef.current = prefilledToolCall.id; + onPrefilledToolCallConsumed?.(prefilledToolCall.id); + }, [appTools, onPrefilledToolCallConsumed, prefilledToolCall]); const handleRefresh = useCallback(() => { listTools(); }, [listTools]); + const executeToolAndOpenApp = useCallback( + async (tool: Tool, toolParams: Record) => { + const runId = ++openAppRunIdRef.current; + const runParams = cloneToolParams(toolParams); + prefillingParamsRef.current = null; + setIsOpeningApp(true); + setSubmittedParams(runParams); + setSubmittedToolResult(null); + try { + const result = await callTool(tool.name, runParams); + + if (runId !== openAppRunIdRef.current) { + return; + } + + setSubmittedParams(runParams); + setSubmittedToolResult(result); + setIsAppOpen(true); + } catch { + if (runId !== openAppRunIdRef.current) { + return; + } + + setSubmittedToolResult(null); + setIsAppOpen(false); + } finally { + if (runId === openAppRunIdRef.current) { + setIsOpeningApp(false); + } + } + }, + [callTool], + ); + const handleCloseApp = useCallback(() => { + openAppRunIdRef.current += 1; + setIsOpeningApp(false); setIsAppOpen(false); + setSubmittedToolResult(null); }, []); - const handleOpenApp = useCallback(() => { - if (!checkValidationErrors()) { - setIsAppOpen(true); + const handleOpenApp = useCallback(async () => { + if (!selectedTool || checkValidationErrors()) { + return; } - }, [checkValidationErrors]); - const handleSelectTool = useCallback((tool: Tool) => { - setSelectedTool(tool); - const hasFields = - tool.inputSchema.properties && - Object.keys(tool.inputSchema.properties).length > 0; - setIsAppOpen(!hasFields); - }, []); + await executeToolAndOpenApp(selectedTool, params); + }, [checkValidationErrors, executeToolAndOpenApp, params, selectedTool]); + + const handleSelectTool = useCallback( + (tool: Tool) => { + openAppRunIdRef.current += 1; + setIsOpeningApp(false); + prefillingParamsRef.current = null; + setSelectedTool(tool); + setSubmittedParams(undefined); + setSubmittedToolResult(null); + const hasFields = + tool.inputSchema.properties && + Object.keys(tool.inputSchema.properties).length > 0; + + if (hasFields) { + setIsAppOpen(false); + return; + } + + const initialParams = buildInitialParams(tool); + void executeToolAndOpenApp(tool, initialParams); + }, + [buildInitialParams, executeToolAndOpenApp], + ); const handleDeselectTool = useCallback(() => { + openAppRunIdRef.current += 1; + setIsOpeningApp(false); + prefillingParamsRef.current = null; setSelectedTool(null); setIsAppOpen(false); setIsMaximized(false); + setSubmittedParams(undefined); + setSubmittedToolResult(null); }, []); return ( @@ -470,12 +612,16 @@ const AppsTab = ({ })}
@@ -497,7 +643,8 @@ const AppsTab = ({ sandboxPath={sandboxPath} tool={selectedTool} mcpClient={mcpClient} - toolInput={params} + toolInput={submittedParams} + toolResult={submittedToolResult} onNotification={onNotification} /> diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index c581e98a..f50c0abb 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -78,7 +78,7 @@ const ToolsTab = ({ params: Record, metadata?: Record, runAsTask?: boolean, - ) => Promise; + ) => Promise; selectedTool: Tool | null; setSelectedTool: (tool: Tool | null) => void; toolResult: CompatibilityCallToolResult | null; diff --git a/client/src/components/__tests__/AppRenderer.test.tsx b/client/src/components/__tests__/AppRenderer.test.tsx index 723e4f98..7d9ae060 100644 --- a/client/src/components/__tests__/AppRenderer.test.tsx +++ b/client/src/components/__tests__/AppRenderer.test.tsx @@ -2,7 +2,10 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, jest, beforeEach } from "@jest/globals"; import AppRenderer from "../AppRenderer"; -import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + Tool, + CompatibilityCallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { RequestHandlerExtra } from "@mcp-ui/client"; import { McpUiMessageResult } from "@modelcontextprotocol/ext-apps"; @@ -16,7 +19,7 @@ type BridgeEvent = { type MockMcpUiRendererProps = { toolName: string; toolInput?: Record; - toolResult?: CallToolResult; + toolResult?: CompatibilityCallToolResult; onMessage?: ( params: { role: "user"; content: { type: "text"; text: string }[] }, extra: RequestHandlerExtra, @@ -111,16 +114,6 @@ jest.mock("@mcp-ui/client", () => { }; }); -const createDeferred = () => { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -}; - describe("AppRenderer", () => { const mockTool: Tool = { name: "testApp", @@ -137,12 +130,6 @@ describe("AppRenderer", () => { } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; const mockMcpClient = { - request: jest.fn( - () => - new Promise(() => { - // Intentionally unresolved for baseline rendering tests. - }), - ), getServerCapabilities: jest.fn().mockReturnValue({}), setNotificationHandler: jest.fn(), } as unknown as Client; @@ -151,6 +138,7 @@ describe("AppRenderer", () => { sandboxPath: "/sandbox", tool: mockTool, mcpClient: mockMcpClient, + toolResult: null, }; beforeEach(() => { @@ -187,38 +175,19 @@ describe("AppRenderer", () => { }); }); - it("should call tools/call and send tool input/result after app initialization", async () => { - const mockResult: CallToolResult = { + it("should send provided tool input and tool result after app initialization", async () => { + const result: CompatibilityCallToolResult = { content: [{ type: "text", text: "Budget initialized" }], }; - const requestMock = jest.fn().mockResolvedValue(mockResult); - const mcpClient = { - ...mockMcpClient, - request: requestMock, - } as unknown as Client; render( , ); - await waitFor(() => { - expect(requestMock).toHaveBeenCalledWith( - { - method: "tools/call", - params: { - name: "testApp", - arguments: { monthlyBudget: 2500 }, - }, - }, - expect.any(Object), - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }); - expect(mockBridgeEvents).toHaveLength(0); fireEvent.click(screen.getByTestId("initialize-app")); @@ -237,26 +206,50 @@ describe("AppRenderer", () => { (event) => event.toolName === "testApp" && event.type === "sendToolResult", ); - expect(resultEvent?.payload).toEqual(mockResult); + expect(resultEvent?.payload).toEqual(result); }); - it("should send an app-consumable error result when tools/call fails", async () => { - const requestMock = jest - .fn() - .mockRejectedValue(new Error("tool execution failed")); - const mcpClient = { - ...mockMcpClient, - request: requestMock, - } as unknown as Client; - + it("should not send tool result event when toolResult is null", async () => { render( - , + , ); + fireEvent.click(screen.getByTestId("initialize-app")); + await waitFor(() => { - expect(requestMock).toHaveBeenCalledTimes(1); + const inputEvents = mockBridgeEvents.filter( + (event) => + event.toolName === "testApp" && event.type === "sendToolInput", + ); + expect(inputEvents).toHaveLength(1); }); + const resultEvents = mockBridgeEvents.filter( + (event) => + event.toolName === "testApp" && event.type === "sendToolResult", + ); + expect(resultEvents).toHaveLength(0); + }); + + it("should normalize compatibility wrapper tool results before sending", async () => { + const wrappedResult = { + toolResult: { + content: [{ type: "text", text: "Wrapped result payload" }], + }, + } as CompatibilityCallToolResult; + + render( + , + ); + fireEvent.click(screen.getByTestId("initialize-app")); await waitFor(() => { @@ -264,95 +257,8 @@ describe("AppRenderer", () => { (event) => event.toolName === "testApp" && event.type === "sendToolResult", ); - - expect(resultEvent?.payload).toEqual({ - content: [{ type: "text", text: "tool execution failed" }], - isError: true, - }); + expect(resultEvent).toBeTruthy(); + expect(resultEvent?.payload).toEqual(wrappedResult.toolResult); }); }); - - it("should ignore stale tool results after switching app tools", async () => { - const firstResult: CallToolResult = { - content: [{ type: "text", text: "stale result" }], - }; - const secondResult: CallToolResult = { - content: [{ type: "text", text: "fresh result" }], - }; - - const firstRequest = createDeferred(); - const secondRequest = createDeferred(); - const requestMock = jest - .fn() - .mockReturnValueOnce(firstRequest.promise) - .mockReturnValueOnce(secondRequest.promise); - - const mcpClient = { - ...mockMcpClient, - request: requestMock, - } as unknown as Client; - - const { rerender } = render( - , - ); - - await waitFor(() => { - expect(requestMock).toHaveBeenCalledTimes(1); - }); - - const secondTool = { - ...mockTool, - name: "budgetAllocatorApp", - }; - - rerender( - , - ); - - await waitFor(() => { - expect(requestMock).toHaveBeenCalledTimes(2); - }); - - secondRequest.resolve(secondResult); - await waitFor(() => { - expect(screen.getByTestId("tool-result")).toHaveTextContent( - "fresh result", - ); - }); - - fireEvent.click(screen.getByTestId("initialize-app")); - - await waitFor(() => { - const freshEvents = mockBridgeEvents.filter( - (event) => - event.toolName === "budgetAllocatorApp" && - event.type === "sendToolResult", - ); - expect(freshEvents).toHaveLength(1); - expect(freshEvents[0].payload).toEqual(secondResult); - }); - - firstRequest.resolve(firstResult); - await Promise.resolve(); - await Promise.resolve(); - - const secondToolResultEvents = mockBridgeEvents.filter( - (event) => - event.toolName === "budgetAllocatorApp" && - event.type === "sendToolResult", - ); - - expect(secondToolResultEvents).toHaveLength(1); - expect(secondToolResultEvents[0].payload).toEqual(secondResult); - expect(screen.getByTestId("tool-result")).toHaveTextContent("fresh result"); - }); }); diff --git a/client/src/components/__tests__/AppsTab.test.tsx b/client/src/components/__tests__/AppsTab.test.tsx index 27868fca..315a95cc 100644 --- a/client/src/components/__tests__/AppsTab.test.tsx +++ b/client/src/components/__tests__/AppsTab.test.tsx @@ -1,8 +1,17 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, jest, beforeEach } from "@jest/globals"; import AppsTab from "../AppsTab"; -import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { + Tool, + CompatibilityCallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; import { Tabs } from "../ui/tabs"; // Mock AppRenderer component @@ -10,14 +19,19 @@ jest.mock("../AppRenderer", () => { return function MockAppRenderer({ tool, toolInput, + toolResult, }: { tool: Tool; toolInput?: Record; + toolResult?: unknown; }) { return (
Tool: {tool.name}
{JSON.stringify(toolInput)}
+
+ {JSON.stringify(toolResult ?? null)} +
); }; @@ -50,6 +64,12 @@ describe("AppsTab", () => { const defaultProps = { tools: [], listTools: jest.fn(), + callTool: jest.fn( + async () => + ({ + content: [], + }) as CompatibilityCallToolResult, + ), error: null, mcpClient: null, }; @@ -131,7 +151,7 @@ describe("AppsTab", () => { expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); - it("should open app renderer when an app card is clicked and Open App button is clicked if fields exist", () => { + it("should open app renderer when an app card is clicked and Open App button is clicked if fields exist", async () => { const toolWithFields: Tool = { name: "fieldsApp", inputSchema: { @@ -142,9 +162,16 @@ describe("AppsTab", () => { }, _meta: { ui: { resourceUri: "ui://fields" } }, } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; + const mockCallTool = jest.fn( + async () => + ({ + content: [], + }) as CompatibilityCallToolResult, + ); renderAppsTab({ tools: [toolWithFields], + callTool: mockCallTool, }); const appCard = screen.getByText("fieldsApp").closest("div"); @@ -159,12 +186,19 @@ describe("AppsTab", () => { const openAppButton = screen.getByRole("button", { name: /open app/i }); fireEvent.click(openAppButton); - // AppRenderer should be rendered - expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + await waitFor(() => { + expect(mockCallTool).toHaveBeenCalledWith("fieldsApp", { + field1: undefined, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); expect(screen.getByText("Tool: fieldsApp")).toBeInTheDocument(); }); - it("should close app renderer when close button is clicked", () => { + it("should close app renderer when close button is clicked", async () => { renderAppsTab({ tools: [mockAppTool], }); @@ -172,8 +206,9 @@ describe("AppsTab", () => { // Open the app const appCard = screen.getByText("weatherApp").closest("div"); fireEvent.click(appCard!); - // weatherApp has no properties in mockAppTool, so it should be open immediately - expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); // Close the app (Deselect tool) const closeButton = screen.getByRole("button", { name: /close app/i }); @@ -204,7 +239,7 @@ describe("AppsTab", () => { expect(screen.getByText("noDescriptionApp")).toBeInTheDocument(); }); - it("should reset selected tool when tools list changes and selected tool is removed", () => { + it("should reset selected tool when tools list changes and selected tool is removed", async () => { const { rerender } = renderAppsTab({ tools: [mockAppTool], }); @@ -212,8 +247,9 @@ describe("AppsTab", () => { // Select the app const appCard = screen.getByText("weatherApp").closest("div"); fireEvent.click(appCard!); - // weatherApp has no properties in mockAppTool, so it should be open immediately - expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); // Update tools list to remove the selected tool rerender( @@ -226,7 +262,7 @@ describe("AppsTab", () => { expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument(); }); - it("should maintain selected tool when tools list updates but includes the same tool", () => { + it("should maintain selected tool when tools list updates but includes the same tool", async () => { const { rerender } = renderAppsTab({ tools: [mockAppTool], }); @@ -234,8 +270,9 @@ describe("AppsTab", () => { // Select the app const appCard = screen.getByText("weatherApp").closest("div"); fireEvent.click(appCard!); - // weatherApp has no properties in mockAppTool, so it should be open immediately - expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); // Update tools list with the same tool rerender( @@ -248,7 +285,7 @@ describe("AppsTab", () => { expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); }); - it("should maximize and minimize the app window", () => { + it("should maximize and minimize the app window", async () => { renderAppsTab({ tools: [mockAppTool], }); @@ -256,12 +293,12 @@ describe("AppsTab", () => { // Select the app const appCard = screen.getByText("weatherApp").closest("div"); fireEvent.click(appCard!); - // weatherApp has no properties in mockAppTool, so it should be open immediately - // Now Maximize button should be visible - expect( - screen.getByRole("button", { name: /maximize/i }), - ).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByRole("button", { name: /maximize/i }), + ).toBeInTheDocument(); + }); // Initially, ListPane should be visible expect(screen.getByText("MCP Apps")).toBeInTheDocument(); @@ -281,7 +318,7 @@ describe("AppsTab", () => { expect(screen.getByText("MCP Apps")).toBeInTheDocument(); }); - it("should reset maximized state when app is closed", () => { + it("should reset maximized state when app is closed", async () => { renderAppsTab({ tools: [mockAppTool], }); @@ -289,7 +326,12 @@ describe("AppsTab", () => { // Select the app const appCard = screen.getByText("weatherApp").closest("div"); fireEvent.click(appCard!); - // weatherApp has no properties in mockAppTool, so it should be open immediately + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /maximize/i }), + ).toBeInTheDocument(); + }); // Maximize fireEvent.click(screen.getByRole("button", { name: /maximize/i })); @@ -311,7 +353,7 @@ describe("AppsTab", () => { expect(appItem).toBeTruthy(); }); - it("should handle empty resourceContentMap", () => { + it("should handle empty resourceContentMap", async () => { renderAppsTab({ tools: [mockAppTool], }); @@ -319,13 +361,13 @@ describe("AppsTab", () => { // Open the app const appCard = screen.getByText("weatherApp").closest("div"); fireEvent.click(appCard!); - // weatherApp has no properties in mockAppTool, so it should be open immediately - // AppRenderer should still be rendered - expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); }); - it("should handle various input types and pass them to AppRenderer", () => { + it("should handle various input types and pass them to AppRenderer", async () => { const toolWithComplexSchema: Tool = { name: "complexApp", inputSchema: { @@ -359,6 +401,10 @@ describe("AppsTab", () => { fireEvent.click(screen.getByRole("button", { name: /open app/i })); + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); + const toolInput = JSON.parse( screen.getByTestId("tool-input").textContent || "{}", ); @@ -367,7 +413,7 @@ describe("AppsTab", () => { expect(toolInput.number).toBe(42); }); - it("should handle nullable fields", () => { + it("should handle nullable fields", async () => { const toolWithNullable: Tool = { name: "nullableApp", inputSchema: { @@ -391,13 +437,17 @@ describe("AppsTab", () => { fireEvent.click(screen.getByRole("button", { name: /open app/i })); + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); + const toolInput = JSON.parse( screen.getByTestId("tool-input").textContent || "{}", ); expect(toolInput.nullableField).toBe(null); }); - it("should allow going back to input form from app renderer", () => { + it("should allow going back to input form from app renderer", async () => { const toolWithFields: Tool = { name: "fieldsApp", inputSchema: { @@ -416,13 +466,15 @@ describe("AppsTab", () => { fireEvent.click(screen.getByText("fieldsApp")); fireEvent.click(screen.getByRole("button", { name: /open app/i })); - expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); const backButton = screen.queryByRole("button", { name: /back to input/i }); expect(backButton).toBeInTheDocument(); }); - it("should skip input form if tool has no input fields", () => { + it("should skip input form if tool has no input fields", async () => { const toolNoFields: Tool = { name: "noFieldsApp", inputSchema: { @@ -431,15 +483,27 @@ describe("AppsTab", () => { }, _meta: { ui: { resourceUri: "ui://no-fields" } }, } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; + const mockCallTool = jest.fn( + async () => + ({ + content: [], + }) as CompatibilityCallToolResult, + ); renderAppsTab({ tools: [toolNoFields], + callTool: mockCallTool, }); fireEvent.click(screen.getByText("noFieldsApp")); - // Should see the renderer immediately - expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + await waitFor(() => { + expect(mockCallTool).toHaveBeenCalledWith("noFieldsApp", {}); + }); + + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); expect(screen.getByText("Tool: noFieldsApp")).toBeInTheDocument(); // Should NOT see the back button @@ -448,7 +512,7 @@ describe("AppsTab", () => { ).not.toBeInTheDocument(); }); - it("should allow going back to input form from app renderer if fields exist", () => { + it("should allow going back to input form from app renderer if fields exist", async () => { const toolWithFields: Tool = { name: "fieldsApp", inputSchema: { @@ -470,7 +534,9 @@ describe("AppsTab", () => { expect(screen.getByText("App Input")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /open app/i })); - expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); const backButton = screen.getByRole("button", { name: /back to input/i }); fireEvent.click(backButton); @@ -478,4 +544,229 @@ describe("AppsTab", () => { expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument(); expect(screen.getByText("App Input")).toBeInTheDocument(); }); + + it("should auto-render app from prefilled tools tab call without manual click", async () => { + const toolWithFields: Tool = { + name: "prefilledApp", + inputSchema: { + type: "object", + properties: { + city: { type: "string" }, + }, + }, + _meta: { ui: { resourceUri: "ui://prefilled" } }, + } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; + const onPrefilledToolCallConsumed = jest.fn(); + const prefilledResult: CompatibilityCallToolResult = { + content: [{ type: "text", text: "weather result" }], + }; + + renderAppsTab({ + tools: [toolWithFields], + prefilledToolCall: { + id: 42, + toolName: "prefilledApp", + params: { city: "Lisbon" }, + result: prefilledResult, + }, + onPrefilledToolCallConsumed, + }); + + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + expect(screen.getByText("Tool: prefilledApp")).toBeInTheDocument(); + }); + + const toolInput = JSON.parse( + screen.getByTestId("tool-input").textContent || "{}", + ); + expect(toolInput).toEqual({ city: "Lisbon" }); + expect(screen.getByTestId("tool-result")).toHaveTextContent( + "weather result", + ); + expect(onPrefilledToolCallConsumed).toHaveBeenCalledWith(42); + }); + + it("should not auto-render app when no prefilled tool call is provided", () => { + const toolWithFields: Tool = { + name: "manualApp", + inputSchema: { + type: "object", + properties: { + city: { type: "string" }, + }, + }, + _meta: { ui: { resourceUri: "ui://manual" } }, + } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; + + renderAppsTab({ + tools: [toolWithFields], + }); + + expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument(); + expect( + screen.getByText("Select an app from the list to get started"), + ).toBeInTheDocument(); + }); + + it("should preserve submitted params while opening app", async () => { + const toolWithFields: Tool = { + name: "paramsApp", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + }, + _meta: { ui: { resourceUri: "ui://params" } }, + } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; + + let resolveCall: ((result: CompatibilityCallToolResult) => void) | null = + null; + const mockCallTool = jest.fn( + () => + new Promise((resolve) => { + resolveCall = resolve; + }), + ); + + renderAppsTab({ + tools: [toolWithFields], + callTool: mockCallTool, + }); + + fireEvent.click(screen.getByText("paramsApp")); + fireEvent.change(screen.getByLabelText("query"), { + target: { value: "first value" }, + }); + fireEvent.click(screen.getByRole("button", { name: /open app/i })); + + fireEvent.change(screen.getByLabelText("query"), { + target: { value: "second value" }, + }); + + expect(resolveCall).toBeTruthy(); + await act(async () => { + resolveCall?.({ + content: [{ type: "text", text: "done" }], + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("app-renderer")).toBeInTheDocument(); + }); + + const toolInput = JSON.parse( + screen.getByTestId("tool-input").textContent || "{}", + ); + expect(toolInput.query).toBe("first value"); + }); + + it("should keep input view when opening fails and recover button state", async () => { + const toolWithFields: Tool = { + name: "failingApp", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + }, + _meta: { ui: { resourceUri: "ui://failing" } }, + } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; + + const mockCallTool = jest.fn( + async () => + await Promise.reject( + new Error("tool failed"), + ), + ); + + renderAppsTab({ + tools: [toolWithFields], + callTool: mockCallTool, + }); + + fireEvent.click(screen.getByText("failingApp")); + fireEvent.click(screen.getByRole("button", { name: /open app/i })); + + await waitFor(() => { + expect(mockCallTool).toHaveBeenCalledWith("failingApp", { + query: undefined, + }); + }); + + await waitFor(() => { + const openAppButton = screen.getByRole("button", { name: /open app/i }); + expect(openAppButton).toBeEnabled(); + }); + + expect(screen.getByText("App Input")).toBeInTheDocument(); + expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument(); + }); + + it("should ignore stale results from older app launches", async () => { + const appA: Tool = { + name: "appA", + inputSchema: { + type: "object", + properties: {}, + }, + _meta: { ui: { resourceUri: "ui://app-a" } }, + } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; + const appB: Tool = { + name: "appB", + inputSchema: { + type: "object", + properties: {}, + }, + _meta: { ui: { resourceUri: "ui://app-b" } }, + } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; + + const pendingCalls: { + name: string; + resolve: (result: CompatibilityCallToolResult) => void; + }[] = []; + const mockCallTool = jest.fn( + (name: string) => + new Promise((resolve) => { + pendingCalls.push({ name, resolve }); + }), + ); + + renderAppsTab({ + tools: [appA, appB], + callTool: mockCallTool, + }); + + fireEvent.click(screen.getByText("appA")); + fireEvent.click(screen.getByText("appB")); + + const appBCall = pendingCalls.find((call) => call.name === "appB"); + const appACall = pendingCalls.find((call) => call.name === "appA"); + expect(appBCall).toBeTruthy(); + expect(appACall).toBeTruthy(); + + await act(async () => { + appBCall?.resolve({ + content: [{ type: "text", text: "result from appB" }], + }); + }); + + await waitFor(() => { + expect(screen.getByText("Tool: appB")).toBeInTheDocument(); + expect(screen.getByTestId("tool-result")).toHaveTextContent("appB"); + }); + + await act(async () => { + appACall?.resolve({ + content: [{ type: "text", text: "result from appA" }], + }); + }); + + await waitFor(() => { + expect(screen.getByText("Tool: appB")).toBeInTheDocument(); + expect(screen.getByTestId("tool-result")).toHaveTextContent("appB"); + expect(screen.getByTestId("tool-result")).not.toHaveTextContent("appA"); + }); + }); }); diff --git a/patches/@mcp-ui+client+6.0.0.patch b/patches/@mcp-ui+client+6.0.0.patch new file mode 100644 index 00000000..f0354e8c --- /dev/null +++ b/patches/@mcp-ui+client+6.0.0.patch @@ -0,0 +1,226 @@ +diff --git a/node_modules/@mcp-ui/client/dist/index.mjs b/node_modules/@mcp-ui/client/dist/index.mjs +index 38fbac3..fa81be4 100644 +--- a/node_modules/@mcp-ui/client/dist/index.mjs ++++ b/node_modules/@mcp-ui/client/dist/index.mjs +@@ -8417,23 +8417,40 @@ const $h = "io.modelcontextprotocol/ui", Ih = { + }, jh = { + [$h]: Ih + }, kh = 1e4; +-async function wh(e) { +- const i = document.createElement("iframe"); +- i.style.width = "100%", i.style.height = "600px", i.style.border = "none", i.style.backgroundColor = "transparent", i.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); +- const r = new Promise((a, t) => { +- let n = !1; +- const o = () => { +- window.removeEventListener("message", u), i.removeEventListener("error", l); +- }, s = setTimeout(() => { +- n || (n = !0, o(), t(new Error("Timed out waiting for sandbox proxy iframe to be ready"))); +- }, kh), u = (d) => { +- d.source === i.contentWindow && d.data && d.data.method === $f && (n || (n = !0, clearTimeout(s), o(), a())); ++function Fh(e) { ++ if (!e || typeof e != "object") ++ return ""; ++ const i = []; ++ return e.camera && i.push("camera"), e.microphone && i.push("microphone"), e.geolocation && i.push("geolocation"), e.clipboardWrite && i.push("clipboard-write"), i.join("; "); ++} ++function Jh(e) { ++ if (!e || typeof e != "object") ++ return {}; ++ const i = "_meta" in e && e._meta && typeof e._meta == "object" ? e._meta : "meta" in e && e.meta && typeof e.meta == "object" ? e.meta : void 0, r = i && typeof i.ui == "object" && i.ui ? i.ui : void 0; ++ return { ++ csp: r && typeof r.csp == "object" ? r.csp : void 0, ++ permissions: r && typeof r.permissions == "object" ? r.permissions : void 0 ++ }; ++} ++async function wh(e, i) { ++ const r = document.createElement("iframe"); ++ r.style.width = "100%", r.style.height = "600px", r.style.border = "none", r.style.backgroundColor = "transparent", r.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); ++ const a = Fh(i); ++ a && r.setAttribute("allow", a); ++ const t = new Promise((n, o) => { ++ let s = !1; ++ const u = () => { ++ window.removeEventListener("message", d), r.removeEventListener("error", l); ++ }, c = setTimeout(() => { ++ s || (s = !0, u(), o(new Error("Timed out waiting for sandbox proxy iframe to be ready"))); ++ }, kh), d = (v) => { ++ v.source === r.contentWindow && v.data && v.data.method === $f && (s || (s = !0, clearTimeout(c), u(), n())); + }, l = () => { +- n || (n = !0, clearTimeout(s), o(), t(new Error("Failed to load sandbox proxy iframe"))); ++ s || (s = !0, clearTimeout(c), u(), o(new Error("Failed to load sandbox proxy iframe"))); + }; +- window.addEventListener("message", u), i.addEventListener("error", l); ++ window.addEventListener("message", d), r.addEventListener("error", l); + }); +- return i.src = e.href, { iframe: i, onReady: r }; ++ return r.src = e.href, { iframe: r, onReady: t }; + } + async function Sh(e, i) { + let r, a; +@@ -8467,7 +8484,12 @@ async function xh(e, i) { + t = atob(a.blob); + else + throw new Error("Unsupported UI resource content format: " + JSON.stringify(a)); +- return t; ++ const o = Jh(a); ++ return { ++ html: t, ++ csp: o.csp, ++ permissions: o.permissions ++ }; + } + function Eh(e, i) { + const r = new URL(e.href); +@@ -8496,7 +8518,7 @@ const zh = (e) => { + var ne, Q; + try { + $.current && ((ne = b.current) != null && ne.contains($.current)) && (b.current.removeChild($.current), $.current = null, I.current = null, N.current = null); +- const { iframe: H, onReady: ge } = await wh(k); ++ const { iframe: H, onReady: ge } = await wh(k, r.permissions); + if (!R || ($.current = H, I.current = V, N.current = a, b.current && b.current.appendChild(H), await ge, !R) || (a.onsizechange = async (ae) => { + var st; + (st = J.current) == null || st.call(J, ae), $.current && (ae.width !== void 0 && ($.current.style.width = `${ae.width}px`), ae.height !== void 0 && ($.current.style.height = `${ae.height}px`)); +@@ -8518,21 +8540,22 @@ const zh = (e) => { + })(), () => { + R = !1; + }; +- }, [r.url, r.csp, a]), q(() => { ++ }, [r.url, r.csp, r.permissions, a]), q(() => { + if (!c || !i || N.current !== a) return; + (async () => { + var V; + try { + console.log("[AppFrame] Sending HTML to sandbox"), await a.sendSandboxResourceReady({ + html: i, +- csp: r.csp ++ csp: r.csp, ++ permissions: r.permissions + }); + } catch (R) { + const ve = R instanceof Error ? R : new Error(String(R)); + g(ve), (V = O.current) == null || V.call(O, ve); + } + })(); +- }, [c, i, a, r.csp]), q(() => { ++ }, [c, i, a, r.csp, r.permissions]), q(() => { + c && l && o && N.current === a && (console.log("[AppFrame] Sending tool input:", o), a.sendToolInput({ arguments: o })); + }, [a, c, l, o]), q(() => { + c && l && s && N.current === a && (console.log("[AppFrame] Sending tool result:", s), a.sendToolResult(s)); +@@ -8574,9 +8597,9 @@ const zh = (e) => { + onListResourceTemplates: J, + onReadResource: U, + onListPrompts: O +- } = e, [k, V] = Se(null), [R, ve] = Se(o ?? null), [ne, Q] = Se(null), H = F(f), ge = F(v), ae = F(g), st = F(b), Ue = F($), er = F(I), tr = F(N), ir = F(J), We = F(U), rr = F(O); ++ } = e, [k, V] = Se(null), [R, ve] = Se(o ?? null), [ne, Q] = Se(null), [H, ge] = Se({ csp: void 0, permissions: void 0 }), ae = F(f), st = F(v), Ue = F(g), er = F(b), tr = F($), ir = F(I), We = F(N), rr = F(J), He = F(U), qe = F(O); + q(() => { +- H.current = f, ge.current = v, ae.current = g, st.current = b, Ue.current = $, er.current = I, tr.current = N, ir.current = J, We.current = U, rr.current = O; ++ ae.current = f, st.current = v, Ue.current = g, er.current = b, tr.current = $, ir.current = I, We.current = N, rr.current = J, He.current = U, qe.current = O; + }), Qa( + i, + () => ({ +@@ -8604,31 +8627,31 @@ const zh = (e) => { + } + ); + if (K.onmessage = async (G, A) => { +- if (H.current) +- return H.current(G, A); ++ if (ae.current) ++ return ae.current(G, A); + throw new ba(ya.MethodNotFound, "Method not found"); + }, K.onopenlink = async (G, A) => { +- if (ge.current) +- return ge.current(G, A); ++ if (st.current) ++ return st.current(G, A); + throw new ba(ya.MethodNotFound, "Method not found"); + }, K.onloggingmessage = (G) => { +- ae.current && ae.current(G); +- }, er.current && (K.oncalltool = (G, A) => er.current(G, A)), tr.current && (K.onlistresources = (G, A) => tr.current(G, A)), ir.current && (K.onlistresourcetemplates = (G, A) => ir.current(G, A)), We.current && (K.onreadresource = (G, A) => We.current(G, A)), rr.current && (K.onlistprompts = (G, A) => rr.current(G, A)), !ut) return; ++ Ue.current && Ue.current(G); ++ }, ir.current && (K.oncalltool = (G, A) => ir.current(G, A)), We.current && (K.onlistresources = (G, A) => We.current(G, A)), rr.current && (K.onlistresourcetemplates = (G, A) => rr.current(G, A)), He.current && (K.onreadresource = (G, A) => He.current(G, A)), qe.current && (K.onlistprompts = (G, A) => qe.current(G, A)), !ut) return; + V(K); + } catch (oe) { + if (console.error("[AppRenderer] Error creating bridge:", oe), !ut) return; + const K = oe instanceof Error ? oe : new Error(String(oe)); +- Q(K), (Oe = Ue.current) == null || Oe.call(Ue, K); ++ Q(K), (Oe = tr.current) == null || Oe.call(tr, K); + } + })(), () => { + ut = !1; + }; + }, [r]), q(() => { + if (o) { +- ve(o); ++ ve(o), ge({ csp: void 0, permissions: void 0 }); + return; + } +- const ut = !!r, ga = !!n && !!We.current; ++ const ut = !!r, ga = !!n && !!He.current; + if (!ut && !ga) { + Q( + new Error( +@@ -8637,6 +8660,7 @@ const zh = (e) => { + ); + return; + } ++ ge({ csp: void 0, permissions: void 0 }); + let Oe = !0; + return (async () => { + var K, G; +@@ -8657,10 +8681,12 @@ const zh = (e) => { + if (!Oe) return; + console.log(`[AppRenderer] Reading resource HTML from: ${A}`); + let Ne; +- if (r) +- Ne = await xh(r, { uri: A }); +- else if (We.current) { +- const Re = await We.current({ uri: A }, {}); ++ if (r) { ++ const Re = await xh(r, { uri: A }); ++ if (!Oe) return; ++ Ne = Re.html, ge({ csp: Re.csp, permissions: Re.permissions }); ++ } else if (He.current) { ++ const Re = await He.current({ uri: A }, {}); + if (!Re.contents || Re.contents.length !== 1) + throw new Error("Unsupported UI resource content length: " + ((K = Re.contents) == null ? void 0 : K.length)); + const _e = Re.contents[0], ha = (dm) => dm === va; +@@ -8670,6 +8696,8 @@ const zh = (e) => { + Ne = atob(_e.blob); + else + throw new Error("Unsupported UI resource content format: " + JSON.stringify(_e)); ++ const mt = Jh(_e); ++ ge({ csp: mt.csp, permissions: mt.permissions }); + } else + throw new Error("No way to read resource HTML"); + if (!Oe) return; +@@ -8677,7 +8705,7 @@ const zh = (e) => { + } catch (A) { + if (!Oe) return; + const Ne = A instanceof Error ? A : new Error(String(A)); +- Q(Ne), (G = Ue.current) == null || G.call(Ue, Ne); ++ Q(Ne), (G = tr.current) == null || G.call(tr, Ne); + } + })(), () => { + Oe = !1; +@@ -8689,7 +8717,11 @@ const zh = (e) => { + }, [k, l]), q(() => { + k && d && k.sendToolCancelled({}); + }, [k, d]); +- const lm = st.current; ++ const lm = er.current, dm = { ++ ...t, ++ csp: H.csp ?? t.csp, ++ permissions: H.permissions ?? t.permissions ++ }; + return ne ? /* @__PURE__ */ bt("div", { style: { color: "red", padding: "1rem" }, children: [ + "Error: ", + ne.message +@@ -8697,7 +8729,7 @@ const zh = (e) => { + zh, + { + html: R, +- sandbox: t, ++ sandbox: dm, + appBridge: k, + toolInput: s, + toolResult: u, From 2a84ce32540144d82c74b9378232d26809816a4a Mon Sep 17 00:00:00 2001 From: Ruben Casas Date: Mon, 16 Feb 2026 16:53:28 +0000 Subject: [PATCH 09/13] chore: remove accidentally committed mcp-ui patch --- patches/@mcp-ui+client+6.0.0.patch | 226 ----------------------------- 1 file changed, 226 deletions(-) delete mode 100644 patches/@mcp-ui+client+6.0.0.patch diff --git a/patches/@mcp-ui+client+6.0.0.patch b/patches/@mcp-ui+client+6.0.0.patch deleted file mode 100644 index f0354e8c..00000000 --- a/patches/@mcp-ui+client+6.0.0.patch +++ /dev/null @@ -1,226 +0,0 @@ -diff --git a/node_modules/@mcp-ui/client/dist/index.mjs b/node_modules/@mcp-ui/client/dist/index.mjs -index 38fbac3..fa81be4 100644 ---- a/node_modules/@mcp-ui/client/dist/index.mjs -+++ b/node_modules/@mcp-ui/client/dist/index.mjs -@@ -8417,23 +8417,40 @@ const $h = "io.modelcontextprotocol/ui", Ih = { - }, jh = { - [$h]: Ih - }, kh = 1e4; --async function wh(e) { -- const i = document.createElement("iframe"); -- i.style.width = "100%", i.style.height = "600px", i.style.border = "none", i.style.backgroundColor = "transparent", i.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); -- const r = new Promise((a, t) => { -- let n = !1; -- const o = () => { -- window.removeEventListener("message", u), i.removeEventListener("error", l); -- }, s = setTimeout(() => { -- n || (n = !0, o(), t(new Error("Timed out waiting for sandbox proxy iframe to be ready"))); -- }, kh), u = (d) => { -- d.source === i.contentWindow && d.data && d.data.method === $f && (n || (n = !0, clearTimeout(s), o(), a())); -+function Fh(e) { -+ if (!e || typeof e != "object") -+ return ""; -+ const i = []; -+ return e.camera && i.push("camera"), e.microphone && i.push("microphone"), e.geolocation && i.push("geolocation"), e.clipboardWrite && i.push("clipboard-write"), i.join("; "); -+} -+function Jh(e) { -+ if (!e || typeof e != "object") -+ return {}; -+ const i = "_meta" in e && e._meta && typeof e._meta == "object" ? e._meta : "meta" in e && e.meta && typeof e.meta == "object" ? e.meta : void 0, r = i && typeof i.ui == "object" && i.ui ? i.ui : void 0; -+ return { -+ csp: r && typeof r.csp == "object" ? r.csp : void 0, -+ permissions: r && typeof r.permissions == "object" ? r.permissions : void 0 -+ }; -+} -+async function wh(e, i) { -+ const r = document.createElement("iframe"); -+ r.style.width = "100%", r.style.height = "600px", r.style.border = "none", r.style.backgroundColor = "transparent", r.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); -+ const a = Fh(i); -+ a && r.setAttribute("allow", a); -+ const t = new Promise((n, o) => { -+ let s = !1; -+ const u = () => { -+ window.removeEventListener("message", d), r.removeEventListener("error", l); -+ }, c = setTimeout(() => { -+ s || (s = !0, u(), o(new Error("Timed out waiting for sandbox proxy iframe to be ready"))); -+ }, kh), d = (v) => { -+ v.source === r.contentWindow && v.data && v.data.method === $f && (s || (s = !0, clearTimeout(c), u(), n())); - }, l = () => { -- n || (n = !0, clearTimeout(s), o(), t(new Error("Failed to load sandbox proxy iframe"))); -+ s || (s = !0, clearTimeout(c), u(), o(new Error("Failed to load sandbox proxy iframe"))); - }; -- window.addEventListener("message", u), i.addEventListener("error", l); -+ window.addEventListener("message", d), r.addEventListener("error", l); - }); -- return i.src = e.href, { iframe: i, onReady: r }; -+ return r.src = e.href, { iframe: r, onReady: t }; - } - async function Sh(e, i) { - let r, a; -@@ -8467,7 +8484,12 @@ async function xh(e, i) { - t = atob(a.blob); - else - throw new Error("Unsupported UI resource content format: " + JSON.stringify(a)); -- return t; -+ const o = Jh(a); -+ return { -+ html: t, -+ csp: o.csp, -+ permissions: o.permissions -+ }; - } - function Eh(e, i) { - const r = new URL(e.href); -@@ -8496,7 +8518,7 @@ const zh = (e) => { - var ne, Q; - try { - $.current && ((ne = b.current) != null && ne.contains($.current)) && (b.current.removeChild($.current), $.current = null, I.current = null, N.current = null); -- const { iframe: H, onReady: ge } = await wh(k); -+ const { iframe: H, onReady: ge } = await wh(k, r.permissions); - if (!R || ($.current = H, I.current = V, N.current = a, b.current && b.current.appendChild(H), await ge, !R) || (a.onsizechange = async (ae) => { - var st; - (st = J.current) == null || st.call(J, ae), $.current && (ae.width !== void 0 && ($.current.style.width = `${ae.width}px`), ae.height !== void 0 && ($.current.style.height = `${ae.height}px`)); -@@ -8518,21 +8540,22 @@ const zh = (e) => { - })(), () => { - R = !1; - }; -- }, [r.url, r.csp, a]), q(() => { -+ }, [r.url, r.csp, r.permissions, a]), q(() => { - if (!c || !i || N.current !== a) return; - (async () => { - var V; - try { - console.log("[AppFrame] Sending HTML to sandbox"), await a.sendSandboxResourceReady({ - html: i, -- csp: r.csp -+ csp: r.csp, -+ permissions: r.permissions - }); - } catch (R) { - const ve = R instanceof Error ? R : new Error(String(R)); - g(ve), (V = O.current) == null || V.call(O, ve); - } - })(); -- }, [c, i, a, r.csp]), q(() => { -+ }, [c, i, a, r.csp, r.permissions]), q(() => { - c && l && o && N.current === a && (console.log("[AppFrame] Sending tool input:", o), a.sendToolInput({ arguments: o })); - }, [a, c, l, o]), q(() => { - c && l && s && N.current === a && (console.log("[AppFrame] Sending tool result:", s), a.sendToolResult(s)); -@@ -8574,9 +8597,9 @@ const zh = (e) => { - onListResourceTemplates: J, - onReadResource: U, - onListPrompts: O -- } = e, [k, V] = Se(null), [R, ve] = Se(o ?? null), [ne, Q] = Se(null), H = F(f), ge = F(v), ae = F(g), st = F(b), Ue = F($), er = F(I), tr = F(N), ir = F(J), We = F(U), rr = F(O); -+ } = e, [k, V] = Se(null), [R, ve] = Se(o ?? null), [ne, Q] = Se(null), [H, ge] = Se({ csp: void 0, permissions: void 0 }), ae = F(f), st = F(v), Ue = F(g), er = F(b), tr = F($), ir = F(I), We = F(N), rr = F(J), He = F(U), qe = F(O); - q(() => { -- H.current = f, ge.current = v, ae.current = g, st.current = b, Ue.current = $, er.current = I, tr.current = N, ir.current = J, We.current = U, rr.current = O; -+ ae.current = f, st.current = v, Ue.current = g, er.current = b, tr.current = $, ir.current = I, We.current = N, rr.current = J, He.current = U, qe.current = O; - }), Qa( - i, - () => ({ -@@ -8604,31 +8627,31 @@ const zh = (e) => { - } - ); - if (K.onmessage = async (G, A) => { -- if (H.current) -- return H.current(G, A); -+ if (ae.current) -+ return ae.current(G, A); - throw new ba(ya.MethodNotFound, "Method not found"); - }, K.onopenlink = async (G, A) => { -- if (ge.current) -- return ge.current(G, A); -+ if (st.current) -+ return st.current(G, A); - throw new ba(ya.MethodNotFound, "Method not found"); - }, K.onloggingmessage = (G) => { -- ae.current && ae.current(G); -- }, er.current && (K.oncalltool = (G, A) => er.current(G, A)), tr.current && (K.onlistresources = (G, A) => tr.current(G, A)), ir.current && (K.onlistresourcetemplates = (G, A) => ir.current(G, A)), We.current && (K.onreadresource = (G, A) => We.current(G, A)), rr.current && (K.onlistprompts = (G, A) => rr.current(G, A)), !ut) return; -+ Ue.current && Ue.current(G); -+ }, ir.current && (K.oncalltool = (G, A) => ir.current(G, A)), We.current && (K.onlistresources = (G, A) => We.current(G, A)), rr.current && (K.onlistresourcetemplates = (G, A) => rr.current(G, A)), He.current && (K.onreadresource = (G, A) => He.current(G, A)), qe.current && (K.onlistprompts = (G, A) => qe.current(G, A)), !ut) return; - V(K); - } catch (oe) { - if (console.error("[AppRenderer] Error creating bridge:", oe), !ut) return; - const K = oe instanceof Error ? oe : new Error(String(oe)); -- Q(K), (Oe = Ue.current) == null || Oe.call(Ue, K); -+ Q(K), (Oe = tr.current) == null || Oe.call(tr, K); - } - })(), () => { - ut = !1; - }; - }, [r]), q(() => { - if (o) { -- ve(o); -+ ve(o), ge({ csp: void 0, permissions: void 0 }); - return; - } -- const ut = !!r, ga = !!n && !!We.current; -+ const ut = !!r, ga = !!n && !!He.current; - if (!ut && !ga) { - Q( - new Error( -@@ -8637,6 +8660,7 @@ const zh = (e) => { - ); - return; - } -+ ge({ csp: void 0, permissions: void 0 }); - let Oe = !0; - return (async () => { - var K, G; -@@ -8657,10 +8681,12 @@ const zh = (e) => { - if (!Oe) return; - console.log(`[AppRenderer] Reading resource HTML from: ${A}`); - let Ne; -- if (r) -- Ne = await xh(r, { uri: A }); -- else if (We.current) { -- const Re = await We.current({ uri: A }, {}); -+ if (r) { -+ const Re = await xh(r, { uri: A }); -+ if (!Oe) return; -+ Ne = Re.html, ge({ csp: Re.csp, permissions: Re.permissions }); -+ } else if (He.current) { -+ const Re = await He.current({ uri: A }, {}); - if (!Re.contents || Re.contents.length !== 1) - throw new Error("Unsupported UI resource content length: " + ((K = Re.contents) == null ? void 0 : K.length)); - const _e = Re.contents[0], ha = (dm) => dm === va; -@@ -8670,6 +8696,8 @@ const zh = (e) => { - Ne = atob(_e.blob); - else - throw new Error("Unsupported UI resource content format: " + JSON.stringify(_e)); -+ const mt = Jh(_e); -+ ge({ csp: mt.csp, permissions: mt.permissions }); - } else - throw new Error("No way to read resource HTML"); - if (!Oe) return; -@@ -8677,7 +8705,7 @@ const zh = (e) => { - } catch (A) { - if (!Oe) return; - const Ne = A instanceof Error ? A : new Error(String(A)); -- Q(Ne), (G = Ue.current) == null || G.call(Ue, Ne); -+ Q(Ne), (G = tr.current) == null || G.call(tr, Ne); - } - })(), () => { - Oe = !1; -@@ -8689,7 +8717,11 @@ const zh = (e) => { - }, [k, l]), q(() => { - k && d && k.sendToolCancelled({}); - }, [k, d]); -- const lm = st.current; -+ const lm = er.current, dm = { -+ ...t, -+ csp: H.csp ?? t.csp, -+ permissions: H.permissions ?? t.permissions -+ }; - return ne ? /* @__PURE__ */ bt("div", { style: { color: "red", padding: "1rem" }, children: [ - "Error: ", - ne.message -@@ -8697,7 +8729,7 @@ const zh = (e) => { - zh, - { - html: R, -- sandbox: t, -+ sandbox: dm, - appBridge: k, - toolInput: s, - toolResult: u, From 87bb2425a3780bef3fce1bb8145c4c7c69eb59be Mon Sep 17 00:00:00 2001 From: Ruben Casas Date: Mon, 16 Feb 2026 22:20:06 +0000 Subject: [PATCH 10/13] fix linting error --- .../__tests__/App.toolsAppsPrefill.test.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/client/src/__tests__/App.toolsAppsPrefill.test.tsx b/client/src/__tests__/App.toolsAppsPrefill.test.tsx index 4931800c..9239f29d 100644 --- a/client/src/__tests__/App.toolsAppsPrefill.test.tsx +++ b/client/src/__tests__/App.toolsAppsPrefill.test.tsx @@ -13,20 +13,18 @@ type ToolListEntry = { _meta: Record; }; -let latestAppsTabProps: - | { - tools: ToolListEntry[]; - prefilledToolCall?: { - id: number; - toolName: string; - params: Record; - result: { - content: Array<{ type: string; text: string }>; - }; - } | null; - onPrefilledToolCallConsumed?: (callId: number) => void; - } - | undefined; +type AppsTabProps = { + tools: ToolListEntry[]; + prefilledToolCall?: { + id: number; + toolName: string; + params: Record; + result: { + content: Array<{ type: string; text: string }>; + }; + } | null; + onPrefilledToolCallConsumed?: (callId: number) => void; +}; // Mock auth dependencies first jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ @@ -164,8 +162,7 @@ jest.mock("../components/ToolsTab", () => ({ jest.mock("../components/AppsTab", () => ({ __esModule: true, - default: (props: typeof latestAppsTabProps) => { - latestAppsTabProps = props; + default: (props: AppsTabProps) => { const prefilled = props && "prefilledToolCall" in props ? props.prefilledToolCall : null; const tools = props && "tools" in props ? props.tools : []; @@ -200,7 +197,6 @@ describe("App - Tools to Apps prefilled handoff", () => { beforeEach(() => { jest.clearAllMocks(); - latestAppsTabProps = undefined; window.location.hash = "#tools"; }); From f2ed4ef3b9434810509958017175e26d9d9068f8 Mon Sep 17 00:00:00 2001 From: Den Delimarsky <53200638+localden@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:57:45 -0800 Subject: [PATCH 11/13] Update SECURITY.md to use GitHub Security Advisories (#1097) --- SECURITY.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 21f8e598..50292420 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,14 +1,21 @@ # Security Policy -Thank you for helping us keep the inspector secure. + +Thank you for helping keep the Model Context Protocol and its ecosystem secure. ## Reporting Security Issues -This project is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. -The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. -Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). +## What to Include -## Vulnerability Disclosure Program +To help us triage and respond quickly, please include: -Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) From 695972a4041f655e01d0de95ce8c99d47b38d25c Mon Sep 17 00:00:00 2001 From: John Howard Date: Thu, 19 Feb 2026 05:00:33 -0800 Subject: [PATCH 12/13] oauth: add tenant suffix to oauth flow (#1096) --- client/src/components/OAuthFlowProgress.tsx | 16 ++++++----- .../__tests__/AuthDebugger.test.tsx | 10 +++++-- client/src/utils/__tests__/oauthUtils.test.ts | 27 ++++++++++++++++++ client/src/utils/oauthUtils.ts | 28 +++++++++++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 6e0fd695..58d4e99f 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react"; import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; import { validateRedirectUrl } from "@/utils/urlValidation"; import { useToast } from "@/lib/hooks/useToast"; +import { getAuthorizationServerMetadataDiscoveryUrl } from "@/utils/oauthUtils"; interface OAuthStepProps { label: string; @@ -81,6 +82,13 @@ export const OAuthFlowProgress = ({ const [clientInfo, setClientInfo] = useState( null, ); + const authorizationServerMetadataDiscoveryUrl = useMemo(() => { + if (!authState.authServerUrl) { + return null; + } + + return getAuthorizationServerMetadataDiscoveryUrl(authState.authServerUrl); + }, [authState.authServerUrl]); const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); @@ -197,13 +205,7 @@ export const OAuthFlowProgress = ({

Authorization Server Metadata:

{authState.authServerUrl && (

- From{" "} - { - new URL( - "/.well-known/oauth-authorization-server", - authState.authServerUrl, - ).href - } + From {authorizationServerMetadataDiscoveryUrl}

)}
diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx
index b05a4898..fec87677 100644
--- a/client/src/components/__tests__/AuthDebugger.test.tsx
+++ b/client/src/components/__tests__/AuthDebugger.test.tsx
@@ -681,7 +681,7 @@ describe("AuthDebugger", () => {
       const updateAuthState = jest.fn();
       const mockResourceMetadata = {
         resource: "https://example.com/mcp",
-        authorization_servers: ["https://custom-auth.example.com"],
+        authorization_servers: ["https://custom-auth.example.com/mcp/tenant"],
         bearer_methods_supported: ["header", "body"],
         resource_documentation: "https://example.com/mcp/docs",
         resource_policy_uri: "https://example.com/mcp/policy",
@@ -733,11 +733,17 @@ describe("AuthDebugger", () => {
         expect(updateAuthState).toHaveBeenCalledWith(
           expect.objectContaining({
             resourceMetadata: mockResourceMetadata,
-            authServerUrl: new URL("https://custom-auth.example.com"),
+            authServerUrl: new URL(
+              "https://custom-auth.example.com/mcp/tenant",
+            ),
             oauthStep: "client_registration",
           }),
         );
       });
+
+      expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
+        new URL("https://custom-auth.example.com/mcp/tenant"),
+      );
     });
 
     it("should handle protected resource metadata fetch failure gracefully", async () => {
diff --git a/client/src/utils/__tests__/oauthUtils.test.ts b/client/src/utils/__tests__/oauthUtils.test.ts
index c42f014e..b885b5ec 100644
--- a/client/src/utils/__tests__/oauthUtils.test.ts
+++ b/client/src/utils/__tests__/oauthUtils.test.ts
@@ -2,6 +2,7 @@ import {
   generateOAuthErrorDescription,
   parseOAuthCallbackParams,
   generateOAuthState,
+  getAuthorizationServerMetadataDiscoveryUrl,
 } from "@/utils/oauthUtils.ts";
 
 describe("parseOAuthCallbackParams", () => {
@@ -84,3 +85,29 @@ describe("generateOAuthErrorDescription", () => {
     });
   });
 });
+
+describe("getAuthorizationServerMetadataDiscoveryUrl", () => {
+  it("uses root discovery URL for root authorization server URL", () => {
+    expect(
+      getAuthorizationServerMetadataDiscoveryUrl("https://example.com"),
+    ).toBe("https://example.com/.well-known/oauth-authorization-server");
+  });
+
+  it("inserts tenant path for non-root authorization server URL", () => {
+    expect(
+      getAuthorizationServerMetadataDiscoveryUrl("https://example.com/tenant1"),
+    ).toBe(
+      "https://example.com/.well-known/oauth-authorization-server/tenant1",
+    );
+  });
+
+  it("strips trailing slash before appending tenant path", () => {
+    expect(
+      getAuthorizationServerMetadataDiscoveryUrl(
+        "https://example.com/tenant1/",
+      ),
+    ).toBe(
+      "https://example.com/.well-known/oauth-authorization-server/tenant1",
+    );
+  });
+});
diff --git a/client/src/utils/oauthUtils.ts b/client/src/utils/oauthUtils.ts
index 9b51179c..cb6faf27 100644
--- a/client/src/utils/oauthUtils.ts
+++ b/client/src/utils/oauthUtils.ts
@@ -87,3 +87,31 @@ export const generateOAuthErrorDescription = (
     .filter(Boolean)
     .join("\n");
 };
+
+/**
+ * Returns the primary OAuth authorization server metadata discovery URL
+ * for a given authorization server URL, including tenant path handling.
+ */
+export const getAuthorizationServerMetadataDiscoveryUrl = (
+  authorizationServerUrl: string | URL,
+): string => {
+  const url =
+    typeof authorizationServerUrl === "string"
+      ? new URL(authorizationServerUrl)
+      : authorizationServerUrl;
+  const hasPath = url.pathname !== "/";
+
+  if (!hasPath) {
+    return new URL("/.well-known/oauth-authorization-server", url.origin).href;
+  }
+
+  // Strip trailing slash to avoid double slashes in tenant-aware discovery URLs.
+  const pathname = url.pathname.endsWith("/")
+    ? url.pathname.slice(0, -1)
+    : url.pathname;
+
+  return new URL(
+    `/.well-known/oauth-authorization-server${pathname}`,
+    url.origin,
+  ).href;
+};

From 6ce441ae70527bb388a65865c71bc6a98bc3932b Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 22 Feb 2026 18:04:00 -0700
Subject: [PATCH 13/13] chore(deps): bump the npm_and_yarn group across 1
 directory with 2 updates (#1106)

Bumps the npm_and_yarn group with 2 updates in the / directory: [ajv](https://github.com/ajv-validator/ajv) and [hono](https://github.com/honojs/hono).


Updates `ajv` from 6.12.6 to 6.14.0
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0)

Updates `hono` from 4.11.7 to 4.12.1
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.11.7...v4.12.1)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 6.14.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: hono
  dependency-version: 4.12.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] 
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 package-lock.json | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index da310ffb..f709c628 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5146,9 +5146,9 @@
       }
     },
     "node_modules/ajv": {
-      "version": "6.12.6",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
-      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+      "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
       "license": "MIT",
       "dependencies": {
         "fast-deep-equal": "^3.1.1",
@@ -7573,9 +7573,9 @@
       }
     },
     "node_modules/hono": {
-      "version": "4.11.7",
-      "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
-      "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
+      "version": "4.12.1",
+      "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.1.tgz",
+      "integrity": "sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw==",
       "license": "MIT",
       "engines": {
         "node": ">=16.9.0"