Skip to content

Commit bd9980c

Browse files
authored
feat: resources in playground (#17)
1 parent 4f93b52 commit bd9980c

File tree

8 files changed

+4011
-6620
lines changed

8 files changed

+4011
-6620
lines changed

.changeset/bright-foxes-play.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@speakeasy-api/docs-mcp-playground": minor
3+
---
4+
5+
Add resources panel to playground UI with expandable markdown previews and extract shared MCP session helpers

packages/playground/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"motion": "^12.0.0",
5454
"react": "^19.0.0",
5555
"react-dom": "^19.0.0",
56+
"react-markdown": "^10.1.0",
5657
"remark-gfm": "^4.0.0",
5758
"shiki": "^3.20.0",
5859
"zustand": "^5.0.0"

packages/playground/src/Playground.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Chat, ElementsConfig, GramElementsProvider } from "@gram-ai/elements";
33
import { ServerUrl } from "./components/ServerUrl.js";
44
import { InstallMethods } from "./components/InstallMethods.js";
55
import { ToolsList } from "./components/ToolsList.js";
6+
import { ResourcesList } from "./components/ResourcesList.js";
67

78
const getSession = async () => {
89
return fetch("/chat/session", {
@@ -35,6 +36,7 @@ export default function Playground() {
3536
<ServerUrl url={mcpUrl} />
3637
<InstallMethods serverUrl={mcpUrl} serverName={serverName} token={token} />
3738
<ToolsList chatEnabled={chatEnabled} />
39+
<ResourcesList />
3840
</div>
3941
);
4042

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { useState, useEffect, useCallback } from "react";
2+
import Markdown from "react-markdown";
3+
import remarkGfm from "remark-gfm";
4+
import { mcpPost, initMcpSession } from "../lib/mcp.js";
5+
6+
interface ResourceEntry {
7+
uri: string;
8+
name: string;
9+
description?: string;
10+
mimeType?: string;
11+
}
12+
13+
export function ResourcesList() {
14+
const [open, setOpen] = useState(false);
15+
const [resources, setResources] = useState<ResourceEntry[]>();
16+
const [sessionId, setSessionId] = useState<string>();
17+
const [expandedUri, setExpandedUri] = useState<string | null>(null);
18+
const [contentCache, setContentCache] = useState<Record<string, string>>({});
19+
const [loadingUri, setLoadingUri] = useState<string | null>(null);
20+
21+
useEffect(() => {
22+
(async () => {
23+
const sid = await initMcpSession();
24+
setSessionId(sid);
25+
const { data } = await mcpPost(
26+
{ jsonrpc: "2.0", id: 2, method: "resources/list", params: {} },
27+
sid,
28+
);
29+
const typed = data as {
30+
result?: { resources?: ResourceEntry[] };
31+
} | null;
32+
setResources(typed?.result?.resources ?? []);
33+
})().catch(() => setResources([]));
34+
}, []);
35+
36+
const handleResourceClick = useCallback(
37+
async (uri: string) => {
38+
if (expandedUri === uri) {
39+
setExpandedUri(null);
40+
return;
41+
}
42+
setExpandedUri(uri);
43+
if (contentCache[uri]) return;
44+
setLoadingUri(uri);
45+
try {
46+
const { data } = await mcpPost(
47+
{ jsonrpc: "2.0", id: 3, method: "resources/read", params: { uri } },
48+
sessionId,
49+
);
50+
const typed = data as {
51+
result?: { contents?: { text: string }[] };
52+
} | null;
53+
const text = typed?.result?.contents?.[0]?.text ?? "";
54+
setContentCache((prev) => ({ ...prev, [uri]: text }));
55+
} catch {
56+
setContentCache((prev) => ({ ...prev, [uri]: "Failed to load resource." }));
57+
} finally {
58+
setLoadingUri(null);
59+
}
60+
},
61+
[expandedUri, contentCache, sessionId],
62+
);
63+
64+
// Hide the section entirely if there are no resources
65+
if (resources !== undefined && resources.length === 0) return null;
66+
67+
const loading = resources === undefined;
68+
69+
return (
70+
<div className="pg-section">
71+
<button className="pg-collapsible-toggle" onClick={() => setOpen(!open)} aria-expanded={open}>
72+
<span className="pg-tool-chevron" data-expanded={open}>
73+
&#9654;
74+
</span>
75+
<span className="pg-heading">Resources</span>
76+
{!loading && resources && <span className="pg-count-badge">{resources.length}</span>}
77+
</button>
78+
79+
{open && (
80+
<div className="pg-tools-grid">
81+
{loading && (
82+
<div className="pg-skeleton-list">
83+
{[1, 2, 3].map((i) => (
84+
<div key={i} className="pg-skeleton" style={{ height: 40 }} />
85+
))}
86+
</div>
87+
)}
88+
{!loading &&
89+
resources?.map((resource) => {
90+
const isExpanded = expandedUri === resource.uri;
91+
const isLoading = loadingUri === resource.uri;
92+
return (
93+
<div key={resource.uri} className="pg-tool-card">
94+
<button
95+
className="pg-tool-header pg-resource-header"
96+
onClick={() => handleResourceClick(resource.uri)}
97+
aria-expanded={isExpanded}
98+
>
99+
<span className="pg-resource-name">{resource.name}</span>
100+
<span className="pg-tool-chevron" data-expanded={isExpanded}>
101+
&#9654;
102+
</span>
103+
</button>
104+
{isExpanded && (
105+
<div className="pg-tool-body">
106+
{isLoading && <div className="pg-skeleton" style={{ height: 100 }} />}
107+
{!isLoading && contentCache[resource.uri] != null && (
108+
<div className="pg-markdown">
109+
<Markdown remarkPlugins={[remarkGfm]}>
110+
{contentCache[resource.uri]}
111+
</Markdown>
112+
</div>
113+
)}
114+
</div>
115+
)}
116+
</div>
117+
);
118+
})}
119+
</div>
120+
)}
121+
</div>
122+
);
123+
}

packages/playground/src/components/ToolsList.tsx

Lines changed: 2 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect } from "react";
22
import { useElements } from "@gram-ai/elements";
33
import { SchemaView } from "./SchemaView.js";
4+
import { mcpPost, initMcpSession } from "../lib/mcp.js";
45

56
interface ToolEntry {
67
name: string;
@@ -34,67 +35,12 @@ function useToolsFromElements(): ToolEntry[] | undefined {
3435
return tools;
3536
}
3637

37-
async function mcpPost(
38-
body: unknown,
39-
sessionId?: string,
40-
): Promise<{ data: unknown; sessionId?: string }> {
41-
const headers: Record<string, string> = {
42-
"Content-Type": "application/json",
43-
Accept: "application/json, text/event-stream",
44-
};
45-
if (sessionId) {
46-
headers["Mcp-Session-Id"] = sessionId;
47-
}
48-
const res = await fetch("/mcp", {
49-
method: "POST",
50-
headers,
51-
body: JSON.stringify(body),
52-
});
53-
const returnedSessionId = res.headers.get("mcp-session-id") ?? sessionId;
54-
const contentType = res.headers.get("content-type") ?? "";
55-
if (contentType.includes("text/event-stream")) {
56-
const text = await res.text();
57-
for (const line of text.split("\n")) {
58-
if (line.startsWith("data: ")) {
59-
return { data: JSON.parse(line.slice(6)), sessionId: returnedSessionId };
60-
}
61-
}
62-
return { data: null, sessionId: returnedSessionId };
63-
}
64-
if (!contentType.includes("application/json")) {
65-
return { data: null, sessionId: returnedSessionId };
66-
}
67-
return { data: await res.json(), sessionId: returnedSessionId };
68-
}
69-
7038
function useToolsFromMcp(): ToolEntry[] | undefined {
7139
const [tools, setTools] = useState<ToolEntry[] | undefined>();
7240

7341
useEffect(() => {
7442
(async () => {
75-
// 1. Initialize handshake
76-
const init = await mcpPost({
77-
jsonrpc: "2.0",
78-
id: 1,
79-
method: "initialize",
80-
params: {
81-
protocolVersion: "2025-03-26",
82-
capabilities: {},
83-
clientInfo: { name: "docs-mcp-playground", version: "0.1.0" },
84-
},
85-
});
86-
const sessionId = init.sessionId;
87-
88-
// 2. Send initialized notification (no id — it's a notification)
89-
await mcpPost(
90-
{
91-
jsonrpc: "2.0",
92-
method: "notifications/initialized",
93-
},
94-
sessionId,
95-
);
96-
97-
// 3. Now list tools
43+
const sessionId = await initMcpSession();
9844
const { data } = await mcpPost(
9945
{
10046
jsonrpc: "2.0",

packages/playground/src/lib/mcp.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export async function mcpPost(
2+
body: unknown,
3+
sessionId?: string,
4+
): Promise<{ data: unknown; sessionId?: string }> {
5+
const headers: Record<string, string> = {
6+
"Content-Type": "application/json",
7+
Accept: "application/json, text/event-stream",
8+
};
9+
if (sessionId) {
10+
headers["Mcp-Session-Id"] = sessionId;
11+
}
12+
const res = await fetch("/mcp", {
13+
method: "POST",
14+
headers,
15+
body: JSON.stringify(body),
16+
});
17+
const returnedSessionId = res.headers.get("mcp-session-id") ?? sessionId;
18+
const contentType = res.headers.get("content-type") ?? "";
19+
if (contentType.includes("text/event-stream")) {
20+
const text = await res.text();
21+
for (const line of text.split("\n")) {
22+
if (line.startsWith("data: ")) {
23+
return { data: JSON.parse(line.slice(6)), sessionId: returnedSessionId };
24+
}
25+
}
26+
return { data: null, sessionId: returnedSessionId };
27+
}
28+
if (!contentType.includes("application/json")) {
29+
return { data: null, sessionId: returnedSessionId };
30+
}
31+
return { data: await res.json(), sessionId: returnedSessionId };
32+
}
33+
34+
export async function initMcpSession(): Promise<string | undefined> {
35+
const init = await mcpPost({
36+
jsonrpc: "2.0",
37+
id: 1,
38+
method: "initialize",
39+
params: {
40+
protocolVersion: "2025-03-26",
41+
capabilities: {},
42+
clientInfo: { name: "docs-mcp-playground", version: "0.1.0" },
43+
},
44+
});
45+
const sessionId = init.sessionId;
46+
await mcpPost({ jsonrpc: "2.0", method: "notifications/initialized" }, sessionId);
47+
return sessionId;
48+
}

0 commit comments

Comments
 (0)