Skip to content
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

Implement MCP Completion Support in Prompts and Resources Tabs #113

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "^0.447.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
83 changes: 71 additions & 12 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
Tool,
ServerCapabilitiesSchema,
Result,
PromptReference,
ResourceReference,
} from "@modelcontextprotocol/sdk/types.js";
import { useCallback, useEffect, useRef, useState } from "react";

Expand Down Expand Up @@ -70,7 +72,8 @@ const App = () => {
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connected" | "error"
>("disconnected");
const [serverCapabilities, setServerCapabilities] = useState<ServerCapabilities | null>(null);
const [serverCapabilities, setServerCapabilities] =
useState<ServerCapabilities | null>(null);
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
Expand Down Expand Up @@ -264,7 +267,8 @@ const App = () => {
});
pushHistory(request, response);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage });
throw error;
} finally {
Expand All @@ -291,6 +295,43 @@ const App = () => {
}
};

const handleCompletion = async (
ref: ResourceReference | PromptReference,
argName: string,
value: string,
signal?: AbortSignal,
) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}

const request: ClientRequest = {
method: "completion/complete",
params: {
argument: {
name: argName,
value,
},
ref,
},
};

try {
const response = await mcpClient.complete(request.params, {
signal,
});
pushHistory(request, response);

return response?.completion.values || [];
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
pushHistory(request, { error: errorMessage });

toast.error(errorMessage);
throw e;
}
};
Comment on lines +298 to +333
Copy link
Member

Choose a reason for hiding this comment

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

Can we use the makeRequest function above?

Copy link
Author

Choose a reason for hiding this comment

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

Sure thing! Will work on this today.

As a side note - I've noticed that the current Spec does not allow servers to indicate whether they support completions, as far as I understand. So if we request completions from a server that does not provide a handler, we get a Method not found error (-32601).

In the short term, I can address this by tracking that specific error and disable further requests for the selected template or prompt.

Long term I feel it would be good to include completions as part of the capability negotiation
e.g.

const server = new Server({
  name: 'My server',
  version: '1.0.0',
}, {
    capabilities: {
      resources: { subscribe: true, completions: true },
      prompts: { completions: true},
    }
  })

Let me know your thoughts or if there's anything I might have overlooked.

Copy link
Member

Choose a reason for hiding this comment

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

Fair callout. If you don't mind, could you open a discussion in the specification repo?

Copy link
Author

Choose a reason for hiding this comment

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


const sendNotification = async (notification: ClientNotification) => {
if (!mcpClient) {
throw new Error("MCP client not connected");
Expand Down Expand Up @@ -505,26 +546,40 @@ const App = () => {
{mcpClient ? (
<Tabs
defaultValue={
Object.keys(serverCapabilities ?? {}).includes(window.location.hash.slice(1)) ?
window.location.hash.slice(1) :
serverCapabilities?.resources ? "resources" :
serverCapabilities?.prompts ? "prompts" :
serverCapabilities?.tools ? "tools" :
"ping"
Object.keys(serverCapabilities ?? {}).includes(
window.location.hash.slice(1),
)
? window.location.hash.slice(1)
: serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping"
}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
>
<TabsList className="mb-4 p-0">
<TabsTrigger value="resources" disabled={!serverCapabilities?.resources}>
<TabsTrigger
value="resources"
disabled={!serverCapabilities?.resources}
>
<Files className="w-4 h-4 mr-2" />
Resources
</TabsTrigger>
<TabsTrigger value="prompts" disabled={!serverCapabilities?.prompts}>
<TabsTrigger
value="prompts"
disabled={!serverCapabilities?.prompts}
>
<MessageSquare className="w-4 h-4 mr-2" />
Prompts
</TabsTrigger>
<TabsTrigger value="tools" disabled={!serverCapabilities?.tools}>
<TabsTrigger
value="tools"
disabled={!serverCapabilities?.tools}
>
<Hammer className="w-4 h-4 mr-2" />
Tools
</TabsTrigger>
Expand All @@ -548,7 +603,9 @@ const App = () => {
</TabsList>

<div className="w-full">
{!serverCapabilities?.resources && !serverCapabilities?.prompts && !serverCapabilities?.tools ? (
{!serverCapabilities?.resources &&
!serverCapabilities?.prompts &&
!serverCapabilities?.tools ? (
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500">
The connected server does not support any MCP capabilities
Expand Down Expand Up @@ -584,6 +641,7 @@ const App = () => {
clearError("resources");
setSelectedResource(resource);
}}
onComplete={handleCompletion}
resourceContent={resourceContent}
nextCursor={nextResourceCursor}
nextTemplateCursor={nextResourceTemplateCursor}
Expand All @@ -608,6 +666,7 @@ const App = () => {
clearError("prompts");
setSelectedPrompt(prompt);
}}
onComplete={handleCompletion}
promptContent={promptContent}
nextCursor={nextPromptCursor}
error={errors.prompts}
Expand Down
45 changes: 38 additions & 7 deletions client/src/components/PromptsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ListPromptsResult } from "@modelcontextprotocol/sdk/types.js";
import {
ListPromptsResult,
PromptReference,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import { useCompletions } from "@/lib/useCompletion";

export type Prompt = {
name: string;
Expand All @@ -26,6 +30,7 @@ const PromptsTab = ({
getPrompt,
selectedPrompt,
setSelectedPrompt,
onComplete,
promptContent,
nextCursor,
error,
Expand All @@ -36,14 +41,37 @@ const PromptsTab = ({
getPrompt: (name: string, args: Record<string, string>) => void;
selectedPrompt: Prompt | null;
setSelectedPrompt: (prompt: Prompt) => void;
onComplete: (
ref: PromptReference,
argName: string,
value: string,
) => Promise<string[]>;
promptContent: string;
nextCursor: ListPromptsResult["nextCursor"];
error: string | null;
}) => {
const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
const { completions, clearCompletions, requestCompletions } = useCompletions({
onComplete,
});

const handleInputChange = (argName: string, value: string) => {
useEffect(() => {
clearCompletions();
}, [clearCompletions, selectedPrompt]);

const handleInputChange = async (argName: string, value: string) => {
setPromptArgs((prev) => ({ ...prev, [argName]: value }));

if (selectedPrompt) {
requestCompletions(
{
type: "ref/prompt",
name: selectedPrompt.name,
},
argName,
value,
);
}
};

const handleGetPrompt = () => {
Expand Down Expand Up @@ -96,14 +124,17 @@ const PromptsTab = ({
{selectedPrompt.arguments?.map((arg) => (
<div key={arg.name}>
<Label htmlFor={arg.name}>{arg.name}</Label>
<Input
<Combobox
id={arg.name}
placeholder={`Enter ${arg.name}`}
value={promptArgs[arg.name] || ""}
onChange={(e) =>
handleInputChange(arg.name, e.target.value)
onChange={(value) => handleInputChange(arg.name, value)}
onInputChange={(value) =>
handleInputChange(arg.name, value)
}
options={completions[arg.name] || []}
/>

{arg.description && (
<p className="text-xs text-gray-500 mt-1">
{arg.description}
Expand Down
58 changes: 43 additions & 15 deletions client/src/components/ResourcesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Combobox } from "@/components/ui/combobox";
import { TabsContent } from "@/components/ui/tabs";
import {
ListResourcesResult,
Resource,
ResourceTemplate,
ListResourceTemplatesResult,
ResourceReference,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
import ListPane from "./ListPane";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useCompletions } from "@/lib/useCompletion";

const ResourcesTab = ({
resources,
Expand All @@ -22,6 +25,7 @@ const ResourcesTab = ({
readResource,
selectedResource,
setSelectedResource,
onComplete,
resourceContent,
nextCursor,
nextTemplateCursor,
Expand All @@ -36,6 +40,11 @@ const ResourcesTab = ({
readResource: (uri: string) => void;
selectedResource: Resource | null;
setSelectedResource: (resource: Resource | null) => void;
onComplete: (
ref: ResourceReference,
argName: string,
value: string,
) => Promise<string[]>;
resourceContent: string;
nextCursor: ListResourcesResult["nextCursor"];
nextTemplateCursor: ListResourceTemplatesResult["nextCursor"];
Expand All @@ -47,6 +56,14 @@ const ResourcesTab = ({
{},
);

const { clearCompletions, completions, requestCompletions } = useCompletions({
onComplete,
});

useEffect(() => {
clearCompletions();
}, [clearCompletions]);

const fillTemplate = (
template: string,
values: Record<string, string>,
Expand All @@ -57,6 +74,21 @@ const ResourcesTab = ({
);
};

const handleTemplateValueChange = async (key: string, value: string) => {
setTemplateValues((prev) => ({ ...prev, [key]: value }));

if (selectedTemplate?.uriTemplate) {
requestCompletions(
{
type: "ref/resource",
uri: selectedTemplate.uriTemplate,
},
key,
value,
);
}
};

const handleReadTemplateResource = () => {
if (selectedTemplate) {
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
Expand Down Expand Up @@ -162,22 +194,18 @@ const ResourcesTab = ({
const key = param.slice(1, -1);
return (
<div key={key}>
<label
htmlFor={key}
className="block text-sm font-medium text-gray-700"
>
{key}
</label>
<Input
<Label htmlFor={key}>{key}</Label>
<Combobox
id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""}
onChange={(e) =>
setTemplateValues({
...templateValues,
[key]: e.target.value,
})
onChange={(value) =>
handleTemplateValueChange(key, value)
}
onInputChange={(value) =>
handleTemplateValueChange(key, value)
}
className="mt-1"
options={completions[key] || []}
/>
</div>
);
Expand Down
Loading