Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
82 changes: 58 additions & 24 deletions mcpjam-inspector/client/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type {
CallToolResult,
ElicitRequest,
Expand Down Expand Up @@ -116,7 +116,7 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
useState<"not_applicable" | "valid" | "invalid_json" | "schema_mismatch">(
"not_applicable",
);
const [loading, setLoading] = useState(false);
const [loadingExecuteTool, setLoadingExecuteTool] = useState(false);
const [fetchingTools, setFetchingTools] = useState(false);
const [error, setError] = useState<string>("");
const [activeElicitation, setActiveElicitation] =
Expand Down Expand Up @@ -147,6 +147,9 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
useState<TaskCapabilities | null>(null);
// TTL for task execution (milliseconds, 0 = no expiration)
const [taskTtl, setTaskTtl] = useState<number>(0);
// Infinite scroll state
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const serverKey = useMemo(() => {
if (!serverConfig) return "none";
try {
Expand Down Expand Up @@ -217,6 +220,38 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
void fetchTaskCapabilities();
}, [serverConfig, serverName]);

const toolNames = Object.keys(tools);
const filteredToolNames = searchQuery.trim()
? toolNames.filter((name) => {
const tool = tools[name];
const haystack = `${name} ${tool?.description ?? ""}`.toLowerCase();
return haystack.includes(searchQuery.trim().toLowerCase());
})
: toolNames;

// IntersectionObserver for infinite scroll
useEffect(() => {
if (!sentinelRef.current) return;
if (activeTab !== "tools") return; // Only observe when tools tab is active

const element = sentinelRef.current;
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (!entry.isIntersecting) return;
if (!cursor || fetchingTools) return;

// Load more tools
fetchTools();
});

observer.observe(element);

return () => {
observer.unobserve(element);
observer.disconnect();
};
}, [filteredToolNames.length, activeTab, cursor, activeTab, fetchingTools]);

// Fetch task capabilities for the server
const fetchTaskCapabilities = async () => {
if (!serverName) return;
Expand Down Expand Up @@ -267,7 +302,6 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {

setFetchingTools(true);
setError("");
setTools({});
setSelectedTool("");
setFormFields([]);
setResult(null);
Expand All @@ -277,12 +311,14 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
setUnstructuredValidationResult("not_applicable");

try {
const data = await listTools(serverName);
// Call to get all of the tools for server
const data = await listTools(serverName, undefined, cursor);
const toolArray = data.tools ?? [];
const dictionary = Object.fromEntries(
toolArray.map((tool: Tool) => [tool.name, tool]),
);
setTools(dictionary);
setTools((prev) => ({ ...prev, ...dictionary }));
setCursor(data.nextCursor);
logger.info("Tools fetched", {
serverId: serverName,
toolCount: toolArray.length,
Expand Down Expand Up @@ -427,7 +463,7 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
return;
}

setLoading(true);
setLoadingExecuteTool(true);
setError("");
setResult(null);
setStructuredResult(null);
Expand Down Expand Up @@ -473,15 +509,20 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
});
setError(message);
} finally {
setLoading(false);
setLoadingExecuteTool(false);
}
};

// Handle Enter key to execute tool globally
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Check if Enter is pressed (not Shift+Enter)
if (e.key === "Enter" && !e.shiftKey && selectedTool && !loading) {
if (
e.key === "Enter" &&
!e.shiftKey &&
selectedTool &&
!loadingExecuteTool
) {
// Don't trigger if user is typing in an input, textarea, or contenteditable
const target = e.target as HTMLElement;
const tagName = target.tagName;
Expand All @@ -498,7 +539,7 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedTool, loading]);
}, [selectedTool, loadingExecuteTool]);

const handleElicitationResponse = async (
action: "accept" | "decline" | "cancel",
Expand Down Expand Up @@ -567,15 +608,6 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
setIsSaveDialogOpen(true);
};

const toolNames = Object.keys(tools);
const filteredToolNames = searchQuery.trim()
? toolNames.filter((name) => {
const tool = tools[name];
const haystack = `${name} ${tool?.description ?? ""}`.toLowerCase();
return haystack.includes(searchQuery.trim().toLowerCase());
})
: toolNames;

const filteredSavedRequests = searchQuery.trim()
? savedRequests.filter((tool) => {
const haystack =
Expand All @@ -588,11 +620,9 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
? {
requestId: activeElicitation.requestId,
message: activeElicitation.request.message,
schema: (
activeElicitation.request as unknown as {
requestedSchema?: Record<string, unknown>;
}
).requestedSchema as Record<string, unknown> | undefined,
schema: (activeElicitation.request as any).requestedSchema as
| Record<string, unknown>
| undefined,
Comment on lines +623 to +625
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard requestedSchema shape before passing to the dialog. Some servers may send non-object schema payloads, which can surface as runtime rendering errors.

🔧 Suggested guard
const requestedSchema = (activeElicitation.request as any).requestedSchema;
const safeSchema =
  requestedSchema &&
  typeof requestedSchema === "object" &&
  !Array.isArray(requestedSchema)
    ? (requestedSchema as Record<string, unknown>)
    : undefined;
🤖 Prompt for AI Agents
In `@mcpjam-inspector/client/src/components/ToolsTab.tsx` around lines 623 - 625,
The code currently passes (activeElicitation.request as any).requestedSchema
directly into the dialog's schema prop which can be a non-object and cause
render errors; update ToolsTab to first read requestedSchema from
activeElicitation.request, validate it is a plain object (typeof === "object"
and not an Array), and only then cast to Record<string, unknown> (otherwise set
undefined) before supplying it to the schema prop so the dialog always receives
a safe object or undefined.

timestamp: activeElicitation.timestamp,
}
: null;
Expand Down Expand Up @@ -630,6 +660,10 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
onRenameRequest={handleRenameRequest}
onDuplicateRequest={handleDuplicateRequest}
onDeleteRequest={handleDeleteRequest}
displayedToolCount={toolNames.length}
sentinelRef={sentinelRef}
loadingMore={fetchingTools}
cursor={cursor ?? ""}
/>
<ResizableHandle withHandle />
{selectedTool ? (
Expand All @@ -638,7 +672,7 @@ export function ToolsTab({ serverConfig, serverName }: ToolsTabProps) {
toolDescription={tools[selectedTool]?.description}
formFields={formFields}
onToggleField={updateFieldIsSet}
loading={loading}
loading={loadingExecuteTool}
waitingOnElicitation={!!activeElicitation}
onExecute={executeTool}
onSave={handleSaveCurrent}
Expand Down
53 changes: 42 additions & 11 deletions mcpjam-inspector/client/src/components/tools/ToolsSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Wrench, RefreshCw } from "lucide-react";
import type { RefObject } from "react";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { ScrollArea } from "../ui/scroll-area";
Expand Down Expand Up @@ -28,6 +29,10 @@ interface ToolsSidebarProps {
onRenameRequest: (req: SavedRequest) => void;
onDuplicateRequest: (req: SavedRequest) => void;
onDeleteRequest: (id: string) => void;
displayedToolCount: number;
sentinelRef: RefObject<HTMLDivElement | null>;
loadingMore: boolean;
cursor: string;
}

export function ToolsSidebar({
Expand All @@ -48,6 +53,10 @@ export function ToolsSidebar({
onRenameRequest,
onDuplicateRequest,
onDeleteRequest,
displayedToolCount,
sentinelRef,
loadingMore,
cursor,
}: ToolsSidebarProps) {
const posthog = usePostHog();
return (
Expand Down Expand Up @@ -146,17 +155,39 @@ export function ToolsSidebar({
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{filteredToolNames.map((name) => (
<ToolItem
key={name}
tool={tools[name]}
name={name}
isSelected={selectedToolName === name}
onClick={() => onSelectTool(name)}
/>
))}
</div>
<>
<div className="grid grid-cols-1 gap-2">
{filteredToolNames
.slice(0, displayedToolCount)
.map((name) => (
<ToolItem
key={name}
tool={tools[name]}
name={name}
isSelected={selectedToolName === name}
onClick={() => onSelectTool(name)}
/>
))}
</div>

{/* Sentinel observed by IntersectionObserver */}
<div ref={sentinelRef} className="h-4" />

{loadingMore && (
<div className="flex items-center justify-center py-3 text-xs text-muted-foreground gap-2">
<RefreshCw className="h-3 w-3 animate-spin" />
<span>Loading more tools…</span>
</div>
)}

{!cursor &&
filteredToolNames.length > 0 &&
!loadingMore && (
<div className="text-center py-3 text-xs text-muted-foreground">
No more tools
</div>
)}
</>
)}
</div>
</ScrollArea>
Expand Down
10 changes: 9 additions & 1 deletion mcpjam-inspector/client/src/hooks/use-app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,15 @@ export function useAppState() {
toast.error(`Network error: ${errorMessage}`);
}
},
[appState.servers, appState.workspaces, appState.activeWorkspaceId, isAuthenticated, logger, fetchAndStoreInitInfo, syncServerToConvex],
[
appState.servers,
appState.workspaces,
appState.activeWorkspaceId,
isAuthenticated,
logger,
fetchAndStoreInitInfo,
syncServerToConvex,
],
);

const saveServerConfigWithoutConnecting = useCallback(
Expand Down
3 changes: 2 additions & 1 deletion mcpjam-inspector/client/src/lib/apis/mcp-tools-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ export type ToolExecutionResponse =
export async function listTools(
serverId: string,
modelId?: string,
cursor?: string,
): Promise<ListToolsResultWithMetadata> {
const res = await fetch("/api/mcp/tools/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serverId, modelId }),
body: JSON.stringify({ serverId, modelId, cursor }),
});
let body: any = null;
try {
Expand Down
11 changes: 9 additions & 2 deletions mcpjam-inspector/server/routes/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,10 @@ function jsonError(c: any, error: unknown, fallbackStatus = 500) {

tools.post("/list", async (c) => {
try {
const { serverId, modelId } = (await c.req.json()) as {
const { serverId, modelId, cursor } = (await c.req.json()) as {
serverId?: string;
modelId?: string;
cursor?: string;
};
if (!serverId) {
return c.json({ error: "serverId is required" }, 400);
Expand All @@ -191,6 +192,7 @@ tools.post("/list", async (c) => {

const result = (await c.mcpClientManager.listTools(
normalizedServerId,
cursor ? { cursor } : undefined,
)) as ListToolsResult;

// Get cached metadata map for O(1) frontend lookups
Expand All @@ -203,7 +205,12 @@ tools.post("/list", async (c) => {
tokenCount = await countToolsTokens(result.tools, modelId);
}

return c.json({ ...result, toolsMetadata, tokenCount });
return c.json({
...result,
toolsMetadata,
tokenCount,
nextCursor: result.nextCursor,
});
} catch (error) {
return jsonError(c, error, 500);
}
Expand Down