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) 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 94a59d84..ebd5d229 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 4586168f..30fadb51 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/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..9239f29d --- /dev/null +++ b/client/src/__tests__/App.toolsAppsPrefill.test.tsx @@ -0,0 +1,284 @@ +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; +}; + +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", () => ({ + 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: AppsTabProps) => { + 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(); + 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 e0c25980..e25f35c9 100644 --- a/client/src/components/AppRenderer.tsx +++ b/client/src/components/AppRenderer.tsx @@ -3,6 +3,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Tool, ContentBlock, + CompatibilityCallToolResult, + CallToolResult, + CallToolResultSchema, ServerNotification, LoggingMessageNotificationParams, } from "@modelcontextprotocol/sdk/types.js"; @@ -24,6 +27,7 @@ interface AppRendererProps { tool: Tool; mcpClient: Client | null; toolInput?: Record; + toolResult?: CompatibilityCallToolResult | null; onNotification?: (notification: ServerNotification) => void; } @@ -32,11 +36,32 @@ const AppRenderer = ({ tool, mcpClient, toolInput, + toolResult, onNotification, }: AppRendererProps) => { const [error, setError] = useState(null); 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") @@ -115,6 +140,7 @@ const AppRenderer = ({ toolName={tool.name} hostContext={hostContext} toolInput={toolInput} + 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/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/ToolsTab.tsx b/client/src/components/ToolsTab.tsx
index 33ea09bc..6f823615 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,
@@ -85,6 +86,84 @@ const getTaskSupport = (
   return "optional";
 };
 
+// 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
+// Shows all 4 annotation values with their state (true/false/implied default)
+const AnnotationBadges = ({
+  annotations,
+}: {
+  annotations: ToolAnnotations | undefined;
+}) => {
+  // 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,
+  });
+
+  const readOnly = getValueAndImplied(annotations?.readOnlyHint, false);
+  const destructive = getValueAndImplied(annotations?.destructiveHint, true);
+  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, description }) => ( + + {value ? "✓" : "✗"} {label} + + ))} +
+ ); +}; + const ToolsTab = ({ tools, listTools, @@ -107,7 +186,7 @@ const ToolsTab = ({ params: Record, metadata?: Record, runAsTask?: boolean, - ) => Promise; + ) => Promise; selectedTool: Tool | null; setSelectedTool: (tool: Tool | null) => void; toolResult: CompatibilityCallToolResult | null; @@ -247,6 +326,13 @@ const ToolsTab = ({

{selectedTool.description}

+ {Object.entries(selectedTool.inputSchema.properties ?? []).map( ([key, value]) => { // First resolve any $ref references diff --git a/client/src/components/__tests__/AppRenderer.test.tsx b/client/src/components/__tests__/AppRenderer.test.tsx index beb57e1a..7d9ae060 100644 --- a/client/src/components/__tests__/AppRenderer.test.tsx +++ b/client/src/components/__tests__/AppRenderer.test.tsx @@ -1,12 +1,33 @@ -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, + 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"; +type BridgeEvent = { + type: "sendToolInput" | "sendToolResult"; + toolName: string; + payload: unknown; +}; + +type MockMcpUiRendererProps = { + toolName: string; + toolInput?: Record; + toolResult?: CompatibilityCallToolResult; + 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 +45,74 @@ 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)} +
+ + +
+ ); + }, + }; +}); describe("AppRenderer", () => { const mockTool: Tool = { @@ -69,7 +130,6 @@ describe("AppRenderer", () => { } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; const mockMcpClient = { - request: jest.fn(), getServerCapabilities: jest.fn().mockReturnValue({}), setNotificationHandler: jest.fn(), } as unknown as Client; @@ -78,10 +138,12 @@ describe("AppRenderer", () => { sandboxPath: "/sandbox", tool: mockTool, mcpClient: mockMcpClient, + toolResult: null, }; beforeEach(() => { jest.clearAllMocks(); + mockBridgeEvents.length = 0; }); it("should display waiting state when mcpClient is null", () => { @@ -112,4 +174,91 @@ describe("AppRenderer", () => { description: "Test message", }); }); + + it("should send provided tool input and tool result after app initialization", async () => { + const result: CompatibilityCallToolResult = { + content: [{ type: "text", text: "Budget initialized" }], + }; + + render( + , + ); + + 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(result); + }); + + it("should not send tool result event when toolResult is null", async () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("initialize-app")); + + await waitFor(() => { + 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(() => { + const resultEvent = mockBridgeEvents.find( + (event) => + event.toolName === "testApp" && event.type === "sendToolResult", + ); + expect(resultEvent).toBeTruthy(); + expect(resultEvent?.payload).toEqual(wrappedResult.toolResult); + }); + }); }); 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/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; +}; diff --git a/package-lock.json b/package-lock.json index 6b4dbc29..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" @@ -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" - } } } } 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 b072c4ce..b5805319 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 && shx cp -R static build",