diff --git a/internal/server/static/css/style.css b/internal/server/static/css/style.css index f12b187c1284..98f7389c6b44 100644 --- a/internal/server/static/css/style.css +++ b/internal/server/static/css/style.css @@ -4,66 +4,101 @@ body { margin: 0; font-family: 'Trebuchet MS'; background-color: #f8f9fa; + box-sizing: border-box; } -.left-nav { - flex: 0 0 200px; - background-color: #fff; - box-shadow: 4px 0px 12px rgba(0, 0, 0, 0.15); - z-index: 10; - display: flex; - flex-direction: column; - padding: 15px; - align-items: center; +*, *:before, *:after { + box-sizing: inherit; } -.nav-logo { - width: 100%; - margin-bottom: 20px; +#navbar-container { + flex: 0 0 250px; + height: 100%; + position: relative; + z-index: 10; } -.nav-logo img { - max-width: 100%; - height: auto; - display: block; +#main-content-container { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow-x: hidden; } -.left-nav ul { +.left-nav { + background-color: #fff; + box-shadow: 4px 0px 12px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + padding: 15px; + align-items: center; + width: 100%; + height: 100%; + z-index: 3; + + ul { font-family: 'Verdana'; list-style: none; padding: 0; margin: 0; width: 100%; -} -.left-nav ul li { - margin-bottom: 5px; -} + li { + margin-bottom: 5px; -.left-nav ul li a { - display: flex; - align-items: center; - padding: 12px; - text-decoration: none; - color: #333; - border-radius: 0; + a { + display: flex; + align-items: center; + padding: 12px; + text-decoration: none; + color: #333; + border-radius: 0; + + &:hover { + background-color: #e9e9e9; + border-radius: 35px; + } + + &.active { + background-color: #d0d0d0; + font-weight: bold; + border-radius: 35px; + } + } + } + } } -.left-nav ul li a:hover { - background-color: #e9e9e9; - border-radius: 35px; +.second-nav { + flex: 0 0 250px; + background-color: #fff; + box-shadow: 4px 0px 12px rgba(0, 0, 0, 0.15); + z-index: 2; + display: flex; + flex-direction: column; + padding: 15px; + align-items: center; + position: relative; } -.left-nav ul li a.active { - background-color: #d0d0d0; - font-weight: bold; - border-radius: 35px; +.nav-logo { + width: 90%; + margin-bottom: 20px; + + img { + max-width: 100%; + height: auto; + display: block; + } } .main-content-area { flex: 1; display: flex; flex-direction: column; + min-width: 0; + overflow-x: hidden; } .top-bar { @@ -75,15 +110,145 @@ body { border-bottom: 1px solid #eee; } -.top-bar span { - font-weight: bold; - font-size: 1.2em; - font-family: 'Trebuchet MS'; - color: #333; -} - .content { padding: 20px; flex-grow: 1; overflow-y: auto; + overflow-x: hidden; +} + +.tool-button { + display: flex; + align-items: center; + padding: 12px; + text-decoration: none; + color: #333; + background-color: transparent; + border: none; + border-radius: 0; + width: 100%; + text-align: left; + cursor: pointer; + font-family: inherit; + font-size: inherit; + + transition: background-color 0.1s ease-in-out, border-radius 0.1s ease-in-out; + + &:hover { + background-color: #e9e9e9; + border-radius: 35px; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(208, 208, 208, 0.5); + } + + &.active { + background-color: #d0d0d0; + font-weight: bold; + border-radius: 35px; + + &:hover { + background-color: #d0d0d0; + } + } +} + +#secondary-panel-content { + ul { + list-style: none; + padding: 0; + margin: 0; + width: 100%; + } +} + +.tool-details-grid { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 20px; + margin: 0 0 20px 0; + align-items: start; +} + +.tool-info { + display: flex; + flex-direction: column; + gap: 15px; +} + +.tool-params { + background-color: #ffffff; + padding: 15px; + border-radius: 4px; + border: 1px solid #ddd; +} + +.tool-box { + background-color: #ffffff; + padding: 15px; + border-radius: 4px; + border: 1px solid #eee; + + h5 { + color: #4285f4; + margin-top: 0; + font-weight: bold; + } +} + +.param-item { + margin-bottom: 12px; + + label { + display: block; + margin-bottom: 4px; + font-family: inherit; + } + + input[type="text"], + input[type="number"], + select, + textarea { + width: calc(100% - 12px); + padding: 6px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; + } + + input[type="checkbox"] { + width: auto; + padding: 0; + border: initial; + border-radius: initial; + vertical-align: middle; + margin-right: 4px; + accent-color: #4285f4; + } +} + +.param-label-extras { + font-style: italic; + font-weight: lighter; + color: rgb(125, 125, 125); +} + +.auth-param-input { + background-color: #e0e0e0; + cursor: not-allowed; +} + +.tool-response { + margin: 20px 0 0 0; + + textarea { + width: 100%; + min-height: 150px; + padding: 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: monospace; + } } diff --git a/internal/server/static/index.html b/internal/server/static/index.html index a64f16ebbd59..b152f69d6a1f 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -7,24 +7,18 @@ - + +
-
-
-
-
-

Homepage

-
-
+ + + diff --git a/internal/server/static/js/mainContent.js b/internal/server/static/js/mainContent.js new file mode 100644 index 000000000000..d1f435972e64 --- /dev/null +++ b/internal/server/static/js/mainContent.js @@ -0,0 +1,39 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Renders the main content area into the HTML. + * @param {string} containerId The ID of the DOM element to inject the content into. + * @param {string | null} idString The id of the item inside the main content area. + */ +function renderMainContent(containerId, idString) { + const mainContentContainer = document.getElementById(containerId); + if (!mainContentContainer) { + console.error(`Content container with ID "${containerId}" not found.`); + return; + } + + const contentHTML = ` +
+
+
+
+

Welcome to MCP Toolbox UI

+

This is the main content area. Click a tab on the left to navigate.

+
+
+ `; + + mainContentContainer.innerHTML = contentHTML; +} diff --git a/internal/server/static/js/navbar.js b/internal/server/static/js/navbar.js new file mode 100644 index 000000000000..72fa6b96af87 --- /dev/null +++ b/internal/server/static/js/navbar.js @@ -0,0 +1,53 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Renders the navigation bar HTML content into the specified container element. + * @param {string} containerId The ID of the DOM element to inject the navbar into. + * @param {string | null} activePath The active tab from the navbar. + */ +function renderNavbar(containerId, activePath) { + const navbarContainer = document.getElementById(containerId); + if (!navbarContainer) { + console.error(`Navbar container with ID "${containerId}" not found.`); + return; + } + + const navbarHTML = ` + + `; + + navbarContainer.innerHTML = navbarHTML; + if (activePath) { + const navLinks = navbarContainer.querySelectorAll('.left-nav ul li a'); + navLinks.forEach(link => { + const linkPath = new URL(link.href).pathname; + if (linkPath === activePath) { + link.classList.add('active'); + } else { + link.classList.remove('active'); + } + }); + } +} diff --git a/internal/server/static/js/toolDisplay.js b/internal/server/static/js/toolDisplay.js new file mode 100644 index 000000000000..f9f3b27534a4 --- /dev/null +++ b/internal/server/static/js/toolDisplay.js @@ -0,0 +1,129 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Helper function to create form inputs for parameters. + */ +function createParamInput(param, toolId) { + const paramItem = document.createElement('div'); + paramItem.className = 'param-item'; + + const label = document.createElement('label'); + const INPUT_ID = `param-${toolId}-${param.name}`; + const NAME_TEXT = document.createTextNode(param.name); + label.setAttribute('for', INPUT_ID); + label.appendChild(NAME_TEXT); + + const IS_AUTH_PARAM = param.authServices && param.authServices.length > 0; + let additionalLabelText = ''; + if (IS_AUTH_PARAM) { + additionalLabelText += ' (auth)'; + } + if (!param.required) { + additionalLabelText += ' (optional)'; + } + + if (additionalLabelText) { + const additionalSpan = document.createElement('span'); + additionalSpan.textContent = additionalLabelText; + additionalSpan.classList.add('param-label-extras'); + label.appendChild(additionalSpan); + } + paramItem.appendChild(label); + + // Build parameter's value input box. + const PLACEHOLDER_LABEL = param.label; + let inputElement; + if (param.type === 'textarea') { + inputElement = document.createElement('textarea'); + inputElement.rows = 3; + } else if(param.type === 'checkbox') { + inputElement = document.createElement('input'); + inputElement.type = 'checkbox'; + inputElement.title = PLACEHOLDER_LABEL; + } else { + inputElement = document.createElement('input'); + inputElement.type = param.type; + } + + inputElement.id = INPUT_ID; + inputElement.name = param.name; + if (IS_AUTH_PARAM) { + inputElement.disabled = true; + inputElement.classList.add('auth-param-input'); + if (param.type !== 'checkbox') { + inputElement.placeholder = param.authServices; + } + } else if (param.type !== 'checkbox') { + inputElement.placeholder = PLACEHOLDER_LABEL.trim(); + } + paramItem.appendChild(inputElement); + return paramItem; +} + +/** + * Renders the tool display area. + */ +export function renderToolInterface(tool, containerElement) { + const TOOL_ID = tool.id; + containerElement.innerHTML = ''; + + const gridContainer = document.createElement('div'); + gridContainer.className = 'tool-details-grid'; + + const toolInfoContainer = document.createElement('div'); + const nameBox = document.createElement('div'); + const descBox = document.createElement('div'); + + nameBox.className = 'tool-box tool-name'; + nameBox.innerHTML = `
Name:

${tool.name}

`; + descBox.className = 'tool-box tool-description'; + descBox.innerHTML = `
Description:

${tool.description}

`; + + toolInfoContainer.className = 'tool-info'; + toolInfoContainer.appendChild(nameBox); + toolInfoContainer.appendChild(descBox); + gridContainer.appendChild(toolInfoContainer); + + const paramsContainer = document.createElement('div'); + const form = document.createElement('form'); + paramsContainer.className = 'tool-params tool-box'; + paramsContainer.innerHTML = '
Parameters:
'; + form.id = `tool-params-form-${TOOL_ID}`; + + tool.parameters.forEach(param => { + form.appendChild(createParamInput(param, TOOL_ID)); + }); + paramsContainer.appendChild(form); + + gridContainer.appendChild(paramsContainer); + containerElement.appendChild(gridContainer); + + const RESPONSE_AREA_ID = `tool-response-area-${TOOL_ID}`; + const responseContainer = document.createElement('div'); + const responseHeader = document.createElement('h5'); + const responseArea = document.createElement('textarea'); + + responseHeader.textContent = 'Response:'; + responseContainer.className = 'tool-response tool-box'; + responseContainer.appendChild(responseHeader); + + responseArea.id = RESPONSE_AREA_ID; + responseArea.readOnly = true; + responseArea.placeholder = 'Results will appear here...'; + responseArea.rows = 10; + responseContainer.appendChild(responseArea); + + containerElement.appendChild(responseContainer); +} diff --git a/internal/server/static/js/tools.js b/internal/server/static/js/tools.js new file mode 100644 index 000000000000..a5584e151b0b --- /dev/null +++ b/internal/server/static/js/tools.js @@ -0,0 +1,169 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { renderToolInterface } from "./toolDisplay.js"; + +/** + * These functions runs after the browser finishes loading and parsing HTML structure. + * This ensures that elements can be safely accessed. + */ +document.addEventListener('DOMContentLoaded', () => { + const toolDisplayArea = document.getElementById('tool-display-area'); + const secondaryPanelContent = document.getElementById('secondary-panel-content'); + + if (!secondaryPanelContent || !toolDisplayArea) { + console.error('Required DOM elements not found.'); + return; + } + + let toolDetailsAbortController = null; + + // fetches tools + async function loadTools() { + secondaryPanelContent.innerHTML = '

Fetching tools...

'; + try { + const response = await fetch('/api/toolset'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const apiResponse = await response.json(); + renderToolList(apiResponse); + } catch (error) { + console.error('Failed to load tools:', error); + secondaryPanelContent.innerHTML = '

Failed to load tools. Please try again later.

'; + } + } + + // renders the fetched tools into the nav bar + function renderToolList(apiResponse) { + secondaryPanelContent.innerHTML = ''; + + if (!apiResponse || typeof apiResponse.tools !== 'object' || apiResponse.tools === null) { + console.error('Error: Expected an object with a "tools" property, but received:', apiResponse); + secondaryPanelContent.textContent = 'Error: Invalid response format from toolset API.'; + return; + } + + const toolsObject = apiResponse.tools; + const toolNames = Object.keys(toolsObject); + + if (toolNames.length === 0) { + secondaryPanelContent.textContent = 'No tools found.'; + return; + } + + const ul = document.createElement('ul'); + toolNames.forEach(toolName => { + const li = document.createElement('li'); + const button = document.createElement('button'); + button.textContent = toolName; + button.dataset.toolname = toolName; + button.classList.add('tool-button'); + button.addEventListener('click', handleToolClick); + li.appendChild(button); + ul.appendChild(li); + }); + secondaryPanelContent.appendChild(ul); + } + + // handles selecting a specific tool from the secondary nav bar + function handleToolClick(event) { + const toolName = event.target.dataset.toolname; + if (toolName) { + const currentActive = secondaryPanelContent.querySelector('.tool-button.active'); + if (currentActive) { + currentActive.classList.remove('active'); + } + event.target.classList.add('active'); + fetchToolDetails(toolName); + } + } + + // fetches details for specific tool + async function fetchToolDetails(toolName) { + + if (toolDetailsAbortController) { + toolDetailsAbortController.abort(); + console.debug("Aborted previous tool fetch."); + } + + toolDetailsAbortController = new AbortController(); + const signal = toolDetailsAbortController.signal; + + toolDisplayArea.innerHTML = '

Loading tool details...

'; + + try { + const response = await fetch(`/api/tool/${encodeURIComponent(toolName)}`, { signal }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const apiResponse = await response.json(); + + if (!apiResponse.tools || !apiResponse.tools[toolName]) { + throw new Error(`Tool "${toolName}" data not found in API response.`); + } + const toolObject = apiResponse.tools[toolName]; + console.debug("Received tool object: ", toolObject) + + const toolInterfaceData = { + id: toolName, + name: toolName, + description: toolObject.description || "No description provided.", + parameters: (toolObject.parameters || []).map(param => { + let inputType = 'text'; + const apiType = param.type ? param.type.toLowerCase() : 'string'; + let valueType = 'string'; + let label = param.description || param.name; + + if (apiType === 'integer' || apiType === 'number') { + inputType = 'number'; + valueType = 'number'; + } else if (apiType === 'boolean') { + inputType = 'checkbox'; + valueType = 'boolean'; + } else if (apiType === 'array') { + inputType = 'textarea'; + const itemType = param.items && param.items.type ? param.items.type.toLowerCase() : 'string'; + valueType = `array<${itemType}>`; + label += ' (Array)'; + } + + return { + name: param.name, + type: inputType, + valueType: valueType, + label: label, + authServices: param.authSources, + required: param.required || false, + // defaultValue: param.default, can't do this yet bc tool manifest doesn't have default + }; + }) + }; + + console.debug("Transformed toolInterfaceData:", toolInterfaceData); + + renderToolInterface(toolInterfaceData, toolDisplayArea); + } catch (error) { + if (error.name === 'AbortError') { + console.debug("Previous fetch was aborted, expected behavior."); + } else { + console.error(`Failed to load details for tool "${toolName}":`, error); + toolDisplayArea.innerHTML = `

Failed to load details for ${toolName}. ${error.message}

`; + } + } + } + + // Initial load of tools list + loadTools(); +}); diff --git a/internal/server/static/tools.html b/internal/server/static/tools.html new file mode 100644 index 000000000000..f139cf447d7a --- /dev/null +++ b/internal/server/static/tools.html @@ -0,0 +1,33 @@ + + + + + + Tools View + + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/internal/server/web.go b/internal/server/web.go index bd0a16054cd1..303436e1acd8 100644 --- a/internal/server/web.go +++ b/internal/server/web.go @@ -22,6 +22,7 @@ func webRouter() (chi.Router, error) { // direct routes for html pages to provide clean URLs r.Get("/", func(w http.ResponseWriter, r *http.Request) { serveHTML(w, r, "static/index.html") }) + r.Get("/tools", func(w http.ResponseWriter, r *http.Request) { serveHTML(w, r, "static/tools.html") }) // handler for all other static files/assets staticFS, _ := fs.Sub(staticContent, "static") diff --git a/internal/server/web_test.go b/internal/server/web_test.go index 877919435187..476367e262de 100644 --- a/internal/server/web_test.go +++ b/internal/server/web_test.go @@ -35,6 +35,14 @@ func TestWebEndpoint(t *testing.T) { wantContentType: "text/html; charset=utf-8", wantPageTitle: "Toolbox UI", }, + { + name: "web tools page GET", + method: http.MethodGet, + path: "/tools", + wantStatus: http.StatusOK, + wantContentType: "text/html; charset=utf-8", + wantPageTitle: "Tools View", + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) {