Skip to content

Commit 282de72

Browse files
aborrusoclaude
andcommitted
web-gui: add intelligent MCP tool selection via Gemini
- Load all available MCP tools on startup (15 tools) - Pass tool list to Gemini with descriptions - Gemini selects appropriate tool and generates arguments - Support multi-type results: datasets, organizations, tags - Organization cards with package count - Status indicator shows which tool is being used - Fallback to ckan_package_search if Gemini fails - Fix: organization queries now work correctly Examples: - "quali organizzazioni con il maggior numero di dataset" → ckan_organization_list - "dataset energia rinnovabile" → ckan_package_search - "trova tag più usati" → ckan_tag_list Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent b90532c commit 282de72

2 files changed

Lines changed: 164 additions & 33 deletions

File tree

LOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
## 2026-01-10
44

5+
### Web GUI intelligent tool selection
6+
- **MCP tool awareness**: Gemini now selects appropriate tool from 15 available
7+
- Loads tool list on startup via `tools/list`
8+
- Passes available tools to Gemini with descriptions
9+
- Gemini chooses tool and generates arguments based on query type
10+
- Examples: `ckan_organization_list` for "organizations with most datasets"
11+
- `ckan_find_relevant_datasets` for smart searches
12+
- `ckan_tag_list` for tag statistics
13+
- **Multi-type results**: UI handles datasets, organizations, tags
14+
- Organization cards show package count
15+
- Dataset cards show resources and org name
16+
- Status shows tool being used ("Using ckan_organization_list...")
17+
- **Fallback**: Defaults to `ckan_package_search` if Gemini fails
18+
- **Fix**: Query "quali organizzazioni con il maggior numero di dataset" now works correctly
19+
520
### Web GUI redesign + conversation context
621
- **UI redesign**: Dark theme with data editorial aesthetic
722
- Typography: DM Serif Display + IBM Plex Sans

web-gui/public/index.html

Lines changed: 149 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,8 @@ <h1 class="serif text-4xl md:text-5xl mb-2 gradient-text">
374374
geminiKey: localStorage.getItem("geminiApiKey") || "",
375375
busy: false,
376376
conversationHistory: [],
377-
settingsExpanded: false
377+
settingsExpanded: false,
378+
availableTools: []
378379
};
379380

380381
elements.endpoint.value = state.endpoint;
@@ -469,6 +470,40 @@ <h1 class="serif text-4xl md:text-5xl mb-2 gradient-text">
469470
return card;
470471
};
471472

473+
const createOrganizationCard = (org) => {
474+
const card = document.createElement("div");
475+
card.className = "dataset-card rounded-xl p-5 cursor-pointer";
476+
card.style.cssText = "background: var(--bg-secondary);";
477+
478+
const title = document.createElement("h3");
479+
title.className = "text-base font-semibold mb-2";
480+
title.style.color = "var(--text-primary)";
481+
title.textContent = org.title || org.display_name || org.name || "Organization";
482+
483+
const desc = document.createElement("p");
484+
desc.className = "text-sm mb-3 line-clamp-2";
485+
desc.style.color = "var(--text-secondary)";
486+
desc.textContent = org.description || "No description available.";
487+
488+
const meta = document.createElement("div");
489+
meta.className = "flex items-center gap-4 text-xs";
490+
meta.style.color = "var(--text-muted)";
491+
492+
const packageCount = org.package_count || org.packages?.length || 0;
493+
494+
meta.innerHTML = `
495+
<span class="flex items-center gap-1">
496+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
497+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
498+
</svg>
499+
${packageCount.toLocaleString()} dataset${packageCount !== 1 ? 's' : ''}
500+
</span>
501+
`;
502+
503+
card.append(title, desc, meta);
504+
return card;
505+
};
506+
472507
const extractStructured = (result) => {
473508
if (!result) return null;
474509
if (result.structuredContent) return result.structuredContent;
@@ -519,10 +554,12 @@ <h1 class="serif text-4xl md:text-5xl mb-2 gradient-text">
519554
const checkMcp = async () => {
520555
setStatus("checking", "Checking...");
521556
try {
522-
await callMcp("tools/list", {});
523-
setStatus("ok", "Online");
557+
const result = await callMcp("tools/list", {});
558+
state.availableTools = result.tools || [];
559+
setStatus("ok", `Online (${state.availableTools.length} tools)`);
524560
} catch (error) {
525561
setStatus("error", "Offline");
562+
state.availableTools = [];
526563
}
527564
};
528565

@@ -549,14 +586,40 @@ <h1 class="serif text-4xl md:text-5xl mb-2 gradient-text">
549586
if (!state.geminiKey) {
550587
throw new Error("Missing Gemini API key");
551588
}
552-
const systemPrompt = "Convert requests to Solr queries for CKAN. Respond only with JSON: {\"q\":\"...\"}. Use conversation context to refine searches.";
589+
590+
// Build tool list for prompt
591+
const toolList = state.availableTools.map(t =>
592+
`- ${t.name}: ${t.description?.substring(0, 100) || 'No description'}`
593+
).join('\n');
594+
595+
const systemPrompt = `You are an assistant for CKAN open data queries.
596+
Available MCP tools:
597+
${toolList}
598+
599+
Choose the most appropriate tool and generate its arguments.
600+
Respond ONLY with JSON in this format:
601+
{
602+
"tool": "tool_name",
603+
"arguments": {
604+
"server_url": "CKAN_SERVER",
605+
...other params based on tool schema
606+
}
607+
}
608+
609+
For dataset searches use ckan_package_search or ckan_find_relevant_datasets.
610+
For organization queries use ckan_organization_list, ckan_organization_show, or ckan_organization_search.
611+
For tags use ckan_tag_list.
612+
Always include server_url parameter.
613+
Use conversation context to refine queries.`;
614+
553615
const contents = [
554616
...history,
555617
{
556618
role: "user",
557-
parts: [{ text: `${systemPrompt}\nRequest: ${userQuery}` }]
619+
parts: [{ text: `${systemPrompt}\n\nCKAN Server: ${state.ckanServer}\nUser request: ${userQuery}` }]
558620
}
559621
];
622+
560623
const response = await fetch(
561624
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${state.geminiKey}`,
562625
{
@@ -567,42 +630,72 @@ <h1 class="serif text-4xl md:text-5xl mb-2 gradient-text">
567630
},
568631
body: JSON.stringify({
569632
contents: contents,
570-
generationConfig: { temperature: 0.2, maxOutputTokens: 200 }
633+
generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
571634
})
572635
}
573636
);
637+
574638
if (!response.ok) {
575639
throw new Error(`Gemini HTTP ${response.status}`);
576640
}
641+
577642
const data = await response.json();
578643
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
579644
const jsonStart = text.indexOf("{");
580645
const jsonEnd = text.lastIndexOf("}");
581646
if (jsonStart === -1 || jsonEnd === -1) {
582647
throw new Error("Invalid Gemini response");
583648
}
649+
584650
const parsed = JSON.parse(text.slice(jsonStart, jsonEnd + 1));
585-
if (!parsed.q) {
586-
throw new Error("Gemini did not return q");
651+
if (!parsed.tool || !parsed.arguments) {
652+
throw new Error("Gemini did not return tool and arguments");
587653
}
588-
return parsed.q;
654+
655+
return parsed;
589656
};
590657

591658
const runSearch = async (query) => {
592-
const normalizedQuery = normalizeQuery(query);
593-
const solrQuery = await callGemini(query, state.conversationHistory).catch(() => normalizedQuery);
594-
setToolStatus("⚡ Searching datasets...");
659+
// Try Gemini to select tool and arguments
660+
let toolCall;
661+
try {
662+
toolCall = await callGemini(query, state.conversationHistory);
663+
} catch (error) {
664+
// Fallback: use ckan_package_search with normalized query
665+
const normalizedQuery = normalizeQuery(query);
666+
toolCall = {
667+
tool: "ckan_package_search",
668+
arguments: {
669+
server_url: state.ckanServer,
670+
q: normalizedQuery,
671+
rows: 5,
672+
response_format: "json"
673+
}
674+
};
675+
}
676+
677+
// Ensure server_url is set
678+
if (!toolCall.arguments.server_url) {
679+
toolCall.arguments.server_url = state.ckanServer;
680+
}
681+
682+
// Ensure response_format is json for programmatic parsing
683+
if (!toolCall.arguments.response_format) {
684+
toolCall.arguments.response_format = "json";
685+
}
686+
687+
setToolStatus(`⚡ Using ${toolCall.tool}...`);
595688
const result = await callMcp("tools/call", {
596-
name: "ckan_package_search",
597-
arguments: {
598-
server_url: state.ckanServer,
599-
q: solrQuery,
600-
rows: 5,
601-
response_format: "json"
602-
}
689+
name: toolCall.tool,
690+
arguments: toolCall.arguments
603691
});
604692
setToolStatus("");
605-
return extractStructured(result);
693+
694+
const data = extractStructured(result);
695+
return {
696+
tool: toolCall.tool,
697+
data: data
698+
};
606699
};
607700

608701
const handleSend = async () => {
@@ -622,8 +715,10 @@ <h1 class="serif text-4xl md:text-5xl mb-2 gradient-text">
622715
});
623716

624717
try {
625-
const data = await runSearch(query);
626-
if (!data || !Array.isArray(data.results)) {
718+
const response = await runSearch(query);
719+
const { tool, data } = response;
720+
721+
if (!data) {
627722
addMessage("assistant", "No results found.");
628723
state.conversationHistory.push({
629724
role: "model",
@@ -635,19 +730,40 @@ <h1 class="serif text-4xl md:text-5xl mb-2 gradient-text">
635730
const container = document.createElement("div");
636731
container.className = "flex flex-col gap-3";
637732

638-
const summary = document.createElement("p");
639-
summary.className = "text-sm font-medium mb-1";
640-
summary.style.color = "var(--primary)";
641-
summary.textContent = `📊 Found ${data.count ?? data.results.length} dataset${(data.count ?? data.results.length) !== 1 ? 's' : ''}`;
642-
container.appendChild(summary);
733+
// Handle different tool types
734+
if (tool.includes("organization")) {
735+
// Organization tools
736+
const items = data.results || data || [];
737+
const summary = document.createElement("p");
738+
summary.className = "text-sm font-medium mb-1";
739+
summary.style.color = "var(--primary)";
740+
summary.textContent = `🏢 Found ${data.count ?? items.length} organization${(data.count ?? items.length) !== 1 ? 's' : ''}`;
741+
container.appendChild(summary);
643742

644-
data.results.forEach((dataset) => container.appendChild(createDatasetCard(dataset)));
645-
addMessage("assistant", container);
743+
items.slice(0, 10).forEach((org) => container.appendChild(createOrganizationCard(org)));
744+
addMessage("assistant", container);
646745

647-
state.conversationHistory.push({
648-
role: "model",
649-
parts: [{ text: `Found ${data.count ?? data.results.length} datasets for "${query}"` }]
650-
});
746+
state.conversationHistory.push({
747+
role: "model",
748+
parts: [{ text: `Found ${data.count ?? items.length} organizations for "${query}"` }]
749+
});
750+
} else {
751+
// Dataset tools (package_search, find_relevant_datasets, etc.)
752+
const items = data.results || data || [];
753+
const summary = document.createElement("p");
754+
summary.className = "text-sm font-medium mb-1";
755+
summary.style.color = "var(--primary)";
756+
summary.textContent = `📊 Found ${data.count ?? items.length} dataset${(data.count ?? items.length) !== 1 ? 's' : ''}`;
757+
container.appendChild(summary);
758+
759+
items.slice(0, 10).forEach((dataset) => container.appendChild(createDatasetCard(dataset)));
760+
addMessage("assistant", container);
761+
762+
state.conversationHistory.push({
763+
role: "model",
764+
parts: [{ text: `Found ${data.count ?? items.length} datasets for "${query}"` }]
765+
});
766+
}
651767
} catch (error) {
652768
addMessage("assistant", `❌ Error: ${error.message || "Could not contact MCP"}`);
653769
state.conversationHistory.push({

0 commit comments

Comments
 (0)