Used for feedback · quests · score card
Add a news tool that fetches real headlines from Google News RSS. Move from hardcoded data to live data sources.
Fetch real headlines from Google News RSS
Turn upstream XML into MCP content
Weather + facts + news in one agent
The news source is Google News RSS. No sign-up, no key, no rate-limit dashboard — just a URL that returns XML. Query it in your browser or with curl before you write any code, so you know what shape the data arrives in.
# Search the feed for a topic curl -s "https://news.google.com/rss/search?q=artificial+intelligence&hl=en-US&gl=US&ceid=US:en" | head -40 # Or the plain top-stories feed, no query curl -s "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en" | head -40
Three query parameters carry the locale: hl is
the language, gl the country, and
ceid pairs the two —
US:en for English,
NO:no for Norwegian. Each
<item> in the response has a
title,
link and
source. That is all this tool needs.
Nothing to add to .env: this tool takes no secret. The one key the workshop
needs is OPENAI_API_KEY for the agent, and you
set that back in module 2.
Add the news tool definition to tools/list.
It takes a required topic and an optional
count parameter.
# Add to the tools list in handle_tools_list() { "name": "get_news", "description": "Get latest news headlines for a topic.", "inputSchema": { "type": "object", "properties": { "topic": { "type": "string", "description": "The news topic to search for" }, "count": { "type": "integer", "description": "Number of headlines to return (default 5, max 10)", "default": 5 } }, "required": ["topic"] } }
// Add to the tools list in HandleToolsList() new { name = "get_news", description = "Get latest news headlines for a topic.", inputSchema = new { type = "object", properties = new { topic = new { type = "string", description = "The news topic to search for" }, count = new { type = "integer", description = "Number of headlines to return (default 5, max 10)", @default = 5 } }, required = new[] { "topic" } } }
// Add to the tools array in handleToolsList() { name: 'get_news', description: 'Get latest news headlines for a topic.', inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'The news topic to search for' }, count: { type: 'integer', description: 'Number of headlines to return (default 5, max 10)', default: 5 } }, required: ['topic'] } }
// Add to the tools slice in HandleToolsList() Tool{ Name: "get_news", Description: "Get latest news headlines for a topic.", InputSchema: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "topic": map[string]interface{}{ "type": "string", "description": "The news topic to search for", }, "count": map[string]interface{}{ "type": "integer", "description": "Number of headlines to return (default 5, max 10)", "default": 5, }, }, "required": []string{"topic"}, }, }
// Both properties are optional, so InputSchema.of() gets no required names. // The model then has to decide whether a topic is worth passing - which is why // the descriptions matter more than the types. new ToolDefinition( "get_news", "Get latest news headlines, optionally filtered by topic.", InputSchema.of(Map.of( "topic", Property.string("Optional topic to search for, e.g. 'technology', 'sports'"), "country", Property.string("Two-letter country code, e.g. 'no', 'us', 'gb'. Default: 'no'"))))
This function calls Google News RSS to fetch real headlines. Unlike the facts tool, this one makes an actual HTTP request to an external service — and parses XML rather than JSON.
import httpx from xml.etree import ElementTree GOOGLE_NEWS_RSS = "https://news.google.com/rss/search" async def get_news(topic: str, count: int = 5) -> str: """Fetch latest news headlines for a given topic. No API key needed.""" count = min(count, 10) # Cap at 10 async with httpx.AsyncClient() as http: resp = await http.get( GOOGLE_NEWS_RSS, params={"q": topic, "hl": "en-US", "gl": "US", "ceid": "US:en"}, timeout=10.0, ) resp.raise_for_status() # Google News RSS is plain XML: channel > item > title / link / source. items = ElementTree.fromstring(resp.text).findall(".//item")[:count] if not items: return f"No news found for topic: {topic}" lines = [f"Top {len(items)} headlines for '{topic}':\n"] for i, item in enumerate(items, 1): title = item.findtext("title", "No title") source = item.findtext("source", "Unknown") lines.append(f"{i}. {title} ({source})") return "\n".join(lines)
public static class NewsTool { private static readonly HttpClient _http = new(); public static async Task<string> GetNews(string topic, int count = 5) { count = Math.Min(count, 10); var url = $"https://news.google.com/rss/search?q={Uri.EscapeDataString(topic)}&hl=en-US&gl=US&ceid=US:en"; var xml = await _http.GetStringAsync(url); // No key to check for - the feed is public. Just parse it. var items = XDocument.Parse(xml).Descendants("item").Take(count).ToList(); if (items.Count == 0) return $"No news found for topic: {topic}"; var sb = new StringBuilder($"Top {items.Count} headlines for '{topic}':\n"); var i = 1; foreach (var item in items) { var title = item.Element("title")?.Value ?? "No title"; var source = item.Element("source")?.Value ?? "Unknown"; sb.AppendLine($"{i++}. {title} ({source})"); } return sb.ToString(); } }
const GOOGLE_NEWS_RSS = 'https://news.google.com/rss/search'; // The feed is small and regular, so one regex beats pulling in an XML parser. function field(block: string, tag: string): string { const m = block.match(new RegExp(`<${tag}[^>]*>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?</${tag}>`)); return m ? m[1].trim() : ''; } export async function getNews(topic: string, count = 5): Promise<string> { count = Math.min(count, 10); const url = new URL(GOOGLE_NEWS_RSS); url.searchParams.set('q', topic); url.searchParams.set('hl', 'en-US'); url.searchParams.set('gl', 'US'); url.searchParams.set('ceid', 'US:en'); const resp = await fetch(url); if (!resp.ok) { return `News feed error: ${resp.status} ${resp.statusText}`; } const xml = await resp.text(); const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].slice(0, count); if (items.length === 0) { return `No news found for topic: ${topic}`; } const lines = [`Top ${items.length} headlines for '${topic}':\n`]; items.forEach((m, i) => { lines.push(`${i + 1}. ${field(m[1], 'title')} (${field(m[1], 'source') || 'Unknown'})`); }); return lines.join('\n'); }
package tools import ( "encoding/xml" "fmt" "net/http" "net/url" "strings" ) func GetNews(topic string, count int) (string, error) { if count > 10 { count = 10 } params := url.Values{} params.Set("q", topic) params.Set("hl", "en-US") params.Set("gl", "US") params.Set("ceid", "US:en") resp, err := http.Get("https://news.google.com/rss/search?" + params.Encode()) if err != nil { return "", err } defer resp.Body.Close() // Google News RSS, not JSON: decode straight into the shape we need. var feed struct { Items []struct { Title string `xml:"title"` Source string `xml:"source"` } `xml:"channel>item"` } if err := xml.NewDecoder(resp.Body).Decode(&feed); err != nil { return "", err } if len(feed.Items) == 0 { return fmt.Sprintf("No news found for topic: %s", topic), nil } if len(feed.Items) > count { feed.Items = feed.Items[:count] } var sb strings.Builder sb.WriteString(fmt.Sprintf("Top %d headlines for '%s':\n", len(feed.Items), topic)) for i, item := range feed.Items { sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, item.Title, item.Source)) } return sb.String(), nil }
package no.javazone.mcp; import java.io.ByteArrayInputStream; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; import javax.xml.parsers.DocumentBuilderFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.w3c.dom.Element; import org.w3c.dom.NodeList; // Google News RSS is public, so there is no secret to manage here. What is left // is the part worth reading: how a failed upstream call surfaces as an MCP error // the model can read and explain, not as a 500. @Component class NewsTool { private final RestClient http = RestClient.create(); ToolResult call(Map<String, Object> arguments) { var topic = arguments.get("topic") instanceof String s && !s.isBlank() ? s : null; var country = arguments.get("country") instanceof String s && !s.isBlank() ? s : "no"; var locale = "no".equals(country) ? "hl=no&gl=NO&ceid=NO:no" : "hl=en-US&gl=US&ceid=US:en"; // A topic means search; no topic means that country's top stories. var uri = topic != null ? "https://news.google.com/rss/search?q=" + URLEncoder.encode(topic, StandardCharsets.UTF_8) + "&" + locale : "https://news.google.com/rss?" + locale; try { var xml = http.get().uri(uri).retrieve().body(String.class); if (xml == null || xml.isBlank()) { return ToolResult.error("News feed error: empty response"); } var articles = parseItems(xml, 5); if (articles.isEmpty()) { // Nothing found is a valid answer, not a failure. return ToolResult.success("No news articles found.", Map.of("articles", List.of())); } var lines = articles.stream() .map(a -> "- %s (%s)".formatted(a.title(), a.source())) .toList(); var label = topic != null ? "'%s'".formatted(topic) : "top headlines"; var text = "News for %s:%n%s".formatted(label, String.join("\n", lines)); return ToolResult.success(text, Map.of("articles", articles, "country", country)); } catch (Exception e) { return ToolResult.error("News feed request failed: " + e.getMessage()); } } private List<Article> parseItems(String xml, int limit) throws Exception { var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); NodeList items = doc.getElementsByTagName("item"); var articles = new ArrayList<Article>(); for (int i = 0; i < Math.min(items.getLength(), limit); i++) { var item = (Element) items.item(i); articles.add(new Article(text(item, "title"), text(item, "link"), text(item, "source"), text(item, "pubDate"))); } return articles; } private static String text(Element item, String tag) { var nodes = item.getElementsByTagName(tag); return nodes.getLength() == 0 ? "" : nodes.item(0).getTextContent(); } private record Article(String title, String url, String source, String publishedAt) {} }
Add the routing for get_news in the
tools/call handler.
# In handle_tools_call(), add a new elif branch: elif tool_name == "get_news": topic = arguments.get("topic", "technology") count = arguments.get("count", 5) result = await get_news(topic, count) return { "content": [{"type": "text", "text": result}], "isError": False }
// In HandleToolsCall(), add a new case: case "get_news": var topic = args.GetProperty("topic").GetString() ?? "technology"; var count = args.TryGetProperty("count", out var c) ? c.GetInt32() : 5; var newsResult = await NewsTool.GetNews(topic, count); return new { content = new[] { new { type = "text", text = newsResult } }, isError = false };
// In handleToolsCall(), add a new case: case 'get_news': { const result = await getNews(args.topic ?? 'technology', args.count ?? 5); return { content: [{ type: 'text', text: result }], isError: false }; }
// In handleToolsCall(), add a new case: case "get_news": topic, _ := args["topic"].(string) if topic == "" { topic = "technology" } count := 5 if c, ok := args["count"].(float64); ok { count = int(c) } result, _ := tools.GetNews(topic, count) return map[string]interface{}{ "content": []map[string]string{{"type": "text", "text": result}}, "isError": false, }, nil
// One more entry in the registry, and the constructor gains one parameter. // Spring wires NewsTool in; the JSON-RPC handler is untouched. McpController(WeatherTool weather, RandomFactTool randomFact, NewsTool news) { this.registry = Map.of( "get_weather_forecast", weather::call, "get_random_fact", randomFact::call, "get_news", news::call); }
Rebuild the MCP server, restart, and test the new tool.
# Rebuild and restart docker compose build mcp-server docker compose up -d # Test the news tool directly curl -X POST http://localhost:8000/message \ -H "Content-Type: application/json" \ -H "MCP-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: get_news" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_news","arguments":{"topic":"artificial intelligence","count":3},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
You should see real news headlines about AI, straight from the Google News feed.
Ask the agent questions that trigger the news tool. Try combining it with other tools to see the LLM orchestrate multiple calls.
# Ask for news curl -X POST http://localhost:8001/query \ -H "Content-Type: application/json" \ -d '{"query": "What are the latest news about AI?"}' # Combine all three tools in one query curl -X POST http://localhost:8001/query \ -H "Content-Type: application/json" \ -d '{"query": "Give me the weather in London, a fun science fact, and top 3 AI news headlines"}'
Multi-tool orchestration: The combined query demonstrates the real power of the agentic loop. The LLM sees all three tools, decides it needs all of them, calls each one through the MCP server, and then synthesizes the results into a single coherent response.
List all tools to confirm your MCP server now exposes three tools — all automatically available to the agent.
# List all tools — you should see all three curl -s -X POST http://localhost:8000/message \ -H "Content-Type: application/json" \ -H "MCP-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/list" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' | python3 -m json.tool
Expected: get_weather,
get_random_fact, and
get_news all listed with their schemas.
What you've proven: The MCP pattern scales cleanly. Each new tool is self-contained in the server. The agent discovers everything dynamically. The LLM decides which tools to call. You could add ten more tools without touching the agent code.
get_news tool that fetches real headlines from Google News RSS
Your feedback helps us improve the workshop.
Your handle is sent with this feedback; leave it blank in the header and the submission stays anonymous. Please keep personal data out of the comment too — no name, e-mail address or employer, yours or anyone else's.