-
Notifications
You must be signed in to change notification settings - Fork 30
POC MCP App #322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
POC MCP App #322
Changes from 4 commits
90ede03
e764dfe
3d0edc6
f62f62e
99fb8eb
55439b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import type { App } from "@modelcontextprotocol/ext-apps"; | ||
| import { createContext, useContext } from "react"; | ||
|
|
||
| export const AppContext = createContext<App | undefined>(undefined); | ||
|
|
||
| export function useApp() { | ||
| const app = useContext(AppContext); | ||
| if (!app) { | ||
| throw new Error("useApp must be used within an AppContext.Provider"); | ||
| } | ||
| return app; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| .list-projects { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
|
|
||
| input { | ||
| padding: 0.2rem; | ||
| } | ||
|
|
||
| .project-list { | ||
| list-style: none; | ||
| padding: 0; | ||
| margin: 0; | ||
|
|
||
| li { | ||
| details { | ||
| padding: 0.75rem; | ||
| margin-bottom: 0.1rem; | ||
| border-radius: 2px; | ||
| color: #212121; | ||
| background: white; | ||
|
|
||
| summary { | ||
| cursor: pointer; | ||
| } | ||
|
|
||
| &:hover { | ||
| background: #ededfc; | ||
| } | ||
|
|
||
| .message, | ||
| .error-list { | ||
| margin-top: 0.5rem; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| .error-list { | ||
| padding: 0; | ||
| list-style: none; | ||
|
|
||
| li { | ||
| margin: 0 0 0.3rem; | ||
| padding: 0.5rem; | ||
| border-radius: 3px; | ||
| background: #ffd7d7; | ||
| } | ||
|
|
||
| .error-header { | ||
| font-weight: bold; | ||
| } | ||
| .error-message { | ||
| margin-top: 0.25rem; | ||
| color: #5a5959; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; | ||
| import { Suspense, use, useMemo, useState } from "react"; | ||
| import { getToolResult } from "../../commonUi/util"; | ||
| import type { ErrorApiView, Project } from "../client/api"; | ||
| import "./ListProjects.css"; | ||
| import { useApp } from "./AppContext"; | ||
|
|
||
| export default function ListProjects(props: { data: CallToolResult }) { | ||
| const { data } = props; | ||
|
|
||
| const projects = useMemo( | ||
| () => getToolResult<{ data: Project[]; count: number }>(data), | ||
| [data], | ||
| ); | ||
|
|
||
| const [searchTerm, setSearchTerm] = useState(""); | ||
|
|
||
| return ( | ||
| <div className="list-projects"> | ||
| <input | ||
| name="search" | ||
| type="text" | ||
| placeholder="Search" | ||
| value={searchTerm} | ||
| onChange={(e) => setSearchTerm(e.target.value)} | ||
| /> | ||
| <ul className="project-list"> | ||
| {projects.data | ||
| .filter((p) => | ||
| p.name | ||
| ?.toLocaleLowerCase() | ||
| .includes(searchTerm.toLocaleLowerCase()), | ||
| ) | ||
| .map((p) => ( | ||
| <ProjectListItem key={p.id} project={p} /> | ||
| ))} | ||
| </ul> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| interface ErrorResult { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. tool results should probably all be exported in some common types for sharing |
||
| data: ErrorApiView[]; | ||
| next_url?: string; | ||
| data_count?: number; | ||
| total_count?: number; | ||
| } | ||
|
|
||
| function ProjectListItem(props: { project: Project }) { | ||
| const { id, name } = props.project; | ||
|
|
||
| const app = useApp(); | ||
| const [projectErrorsResource, setProjectErrorsResource] = | ||
| useState<Promise<ErrorResult>>(); | ||
|
|
||
| /** | ||
| * When expanded, load the top project errors | ||
| * When collapsed, clear the errors | ||
| */ | ||
| const handleToggle = (event: React.ToggleEvent) => { | ||
| if (event.newState === "open") { | ||
| setProjectErrorsResource( | ||
| app | ||
| .callServerTool({ | ||
| name: "bugsnag_list_project_errors", | ||
| arguments: { projectId: id }, | ||
| }) | ||
| .then((result) => getToolResult(result)), | ||
| ); | ||
| } else { | ||
| setProjectErrorsResource(undefined); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <li> | ||
| <details onToggle={handleToggle}> | ||
| <summary>{name}</summary> | ||
| <Suspense fallback={<div className="message">Loading...</div>}> | ||
| {projectErrorsResource && ( | ||
| <ProjectErrors resource={projectErrorsResource} /> | ||
| )} | ||
| </Suspense> | ||
| </details> | ||
| </li> | ||
| ); | ||
| } | ||
|
|
||
| function ProjectErrors(props: { resource: Promise<ErrorResult> }) { | ||
| const errors = use(props.resource).data; | ||
|
|
||
| if (errors.length === 0) { | ||
| return <div className="message">No errors found for this project.</div>; | ||
| } | ||
|
|
||
| return ( | ||
| <ul className="error-list"> | ||
| {errors.map((e) => ( | ||
| <li key={e.id}> | ||
| <div className="error-header"> | ||
| {e.error_class} {e.context} | ||
| </div> | ||
| <div className="error-message">{e.message}</div> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # MCP App POC | ||
|
|
||
| See https://modelcontextprotocol.io/docs/extensions/apps | ||
|
|
||
| When running the MCP server locally from the built dist dir. The app will be served by the MCP server. | ||
| The HTML file is read directly once and cached and assets are served from /assets. | ||
|
|
||
| ### Local development | ||
| To improve the development experience, you can run the vite dev server, and start the MCP server with UI_DEV=true. | ||
| This will cause the MCP server to proxy the html from the dev server and the dev server will handle the assets. | ||
| This allows hot reloading to work and a far better experience. | ||
|
|
||
| ``` | ||
| $ UI_DEV=1 node dist/server.js | ||
| ``` | ||
|
|
||
| To use the basic-host app for testing, see https://modelcontextprotocol.io/docs/extensions/apps#testing-with-the-basic-host. | ||
|
|
||
| ``` | ||
| $ SERVERS='["http://localhost:3000/mcp"]' npm start | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="mcp-tool-id" content="{{tool}}"> | ||
| <title>BugSnag MCP App</title> | ||
| </head> | ||
| <body> | ||
| <div id="container"></div> | ||
| <script type="module" src="app.tsx"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,26 @@ | ||||
| import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; | ||||
| import { lazy } from "react"; | ||||
| import "./ListProjects.css"; | ||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
| import { createMcpApp } from "../../commonUi/util"; | ||||
|
|
||||
| const ListProjects = lazy(() => import("./ListProjects")); | ||||
|
|
||||
| createMcpApp({ | ||||
| name: "BugSnag MCP App", | ||||
| version: "0.0.1", | ||||
| RootComponent: Router, | ||||
| }); | ||||
|
|
||||
| /** | ||||
| * Based on the tool that was called, render the appropriate component. | ||||
| * | ||||
| * The routes should be imported lazily. | ||||
| */ | ||||
| function Router({ toolId, data }: { toolId: string; data: CallToolResult }) { | ||||
| switch (toolId) { | ||||
| case "list-projects": | ||||
| return <ListProjects data={data} />; | ||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding a handling for a new tool would just be a case of adding a new case with a lazy import. |
||||
| default: | ||||
| throw new Error(`Unknown tool ID: ${toolId}`); | ||||
| } | ||||
| } | ||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -90,6 +90,7 @@ export class SmartBearMcpServer extends McpServer { | |
| inputSchema: this.getInputSchema(params), | ||
| outputSchema: this.getOutputSchema(params), | ||
| annotations: this.getAnnotations(toolTitle, params), | ||
| ...(params._meta && { _meta: params._meta }), | ||
| }, | ||
| async (args: any, extra: any) => { | ||
| try { | ||
|
|
@@ -156,7 +157,7 @@ export class SmartBearMcpServer extends McpServer { | |
|
|
||
| if (client.registerResources) { | ||
| client.registerResources((name, path, cb) => { | ||
| const url = `${client.toolPrefix}://${name}/${path}`; | ||
| const url = typeof path === 'string' ? `${client.toolPrefix}://${name}/${path}` : path.uri; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the default uri construction doesn't work for mcp apps. the uri schema must be |
||
| return super.registerResource( | ||
| name, | ||
| new ResourceTemplate(url, { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
adding a new tool call is just a case of adding the correct meta to another tool. The app will then be passed the toolId and data to handle.