Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
857 changes: 824 additions & 33 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
"mcpServerName": "SmartBear MCP Server"
},
"scripts": {
"build": "vite build && shx chmod +x dist/*.js",
"build": "vite build && npm run ui && shx chmod +x dist/*.js",
"ui": "vite build --config vite.config.ui.ts",
"ui:dev": "vite --config vite.config.ui.ts",
"lint": "biome lint .",
"lint:fix": "biome lint . --fix",
"format": "biome format . --write",
Expand All @@ -48,8 +50,11 @@
},
"dependencies": {
"@bugsnag/js": "^8.2.0",
"@modelcontextprotocol/sdk": "^1.15.0",
"@modelcontextprotocol/ext-apps": "^1.0.1",
"@modelcontextprotocol/sdk": "^1.26.0",
"node-cache": "^5.1.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"swagger-client": "^3.35.6",
"vite": "^7.3.1",
"zod": "^4"
Expand All @@ -58,6 +63,9 @@
"@biomejs/biome": "^2.2.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.3",
"@vitest/coverage-v8": "^3.2.4",
"globals": "^16.2.0",
"shx": "^0.3.4",
Expand Down
19 changes: 13 additions & 6 deletions src/bugsnag/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import type { CacheService } from "../common/cache";
import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "../common/info";
import type { SmartBearMcpServer } from "../common/server";
import { ToolError } from "../common/tools";
import type {
import {
Client,
GetInputFunction,
RegisterResourceFunction,
RegisterToolsFunction,
type GetInputFunction,
type RegisterResourceFunction,
type RegisterToolsFunction,
} from "../common/types";
import {
type Build,
Expand All @@ -21,7 +21,7 @@ import {
ProjectAPI,
type Release,
type TraceField,
} from "./client/api/index";
} from "./client/api";
import { type FilterObject, toUrlSearchParams } from "./client/filters";
import { toolInputParameters } from "./input-schemas";

Expand Down Expand Up @@ -67,7 +67,7 @@ const ConfigurationSchema = z.object({
endpoint: z.url().describe("BugSnag endpoint URL").optional(),
});

export class BugsnagClient implements Client {
export class BugsnagClient extends Client {
private cache?: CacheService;
private projectApiKey?: string;
private configuredProjectApiKey?: string;
Expand Down Expand Up @@ -443,6 +443,11 @@ export class BugsnagClient implements Client {
hints: [
"Project IDs from this list can be used with other tools when no project API key is configured",
],
_meta: {
ui: {
resourceUri: this.createAppUri("list-projects"),
Copy link
Copy Markdown
Contributor Author

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.

},
},
},
async (args, _extra) => {
const params = listProjectsInputSchema.parse(args);
Expand Down Expand Up @@ -1702,5 +1707,7 @@ export class BugsnagClient implements Client {
],
};
});

this.registerUIResource(register);
}
}
12 changes: 12 additions & 0 deletions src/bugsnag/ui/AppContext.ts
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;
}
58 changes: 58 additions & 0 deletions src/bugsnag/ui/ListProjects.css
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;
}
}
}
108 changes: 108 additions & 0 deletions src/bugsnag/ui/ListProjects.tsx
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 {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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}&ensp;{e.context}
</div>
<div className="error-message">{e.message}</div>
</li>
))}
</ul>
);
}
21 changes: 21 additions & 0 deletions src/bugsnag/ui/README.md
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
```
12 changes: 12 additions & 0 deletions src/bugsnag/ui/app.html
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>
26 changes: 26 additions & 0 deletions src/bugsnag/ui/app.tsx
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";
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import "./ListProjects.css";

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} />;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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}`);
}
}
3 changes: 2 additions & 1 deletion src/common/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 ui://.... so I'm allowing passing a complete uri to override it here.

return super.registerResource(
name,
new ResourceTemplate(url, {
Expand Down
Loading
Loading