Heeey everyone! Are you all right? everything in peace? Everything calm? I hope you are well!
It's been a while since I've posted anything here, but I'm back!
In this article I want to introduce the new feature of Chrome: WebMCP. I want to introduce you to the concept and create an application combining WebMCP and Chrome's natively integrated AI Web APIs. So yeah, IT WILL BE AWESOME.
After that, let's create a real application with integration Dev.To API's to list posts, search by author, add to your favorites and summarize the post (using the WebAI).
In this article I won't explain what is a MCP (Model Context Protocol), because I explained in another article, where I taught how to create your own MCP with TypeScript.
You can find the artile here.
But, What is WebMCP?
As the web of agents evolves, Google want to help websites play an active role in how AI agents interact with them. WebMCP aims to provide a standard way to expose structured tools, ensuring that AI agents can perform actions on your website with greater speed, reliability, and accuracy.
By defining these tools, you tell agents how and where to interact with your website, whether it's booking a flight, submitting a support ticket, or navigating complex data. This direct communication channel eliminates ambiguity and enables faster and more robust agent workflows.
Initially the Google Team developed an extension to test your WebMCP, I'm goig to talk later. But you'll need a Gemini API KEY.
Pre-requisites
WebMCP is only available on Chrome 146+. For this I recommend you install the Chrome Canary or Chrome Dev versions.
Install the WebMCP Tool Inspector
For the frontend project, I'm going to use Vite with React 19, Typescript, Tailwind and Zustand.
Enable WebMCP and Summarize API on Chrome Flags: Access Chrome Flags and enable the
Summarizer APIandWebMCP for testing.
Project Setup
pnpm create vite
On the options:
- Give the name of your project
- React
- Typescript
- I'm using pnpm, but you can use yarn ou npm.
Let's add the dependencies:
pnpm add tailwindcss @tailwindcss/vite zustand
You can remove the file and the folder:
- App.css
- assets
After that remove the imports of this files/].
With project created and cleaned, let's setup the tailwind in our project:
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
});
index.css
@import "tailwindcss";
body {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
Web API's declarations of typescript it was not defined yet. Because of that, we need to define ourselves. So let's create the file global.d.ts inside the source folder.
We'll going to use only the Summary API of WebAI.
src/global.d.ts
/**
* Global type declarations for browser APIs not in default TypeScript libs.
*/
/** Chrome Summarizer API (Chrome 138+, desktop) */
interface SummarizerAPI {
availability(): Promise<
"readily" | "after-download" | "no-model" | "unavailable"
>;
create(options: SummarizerCreateOptions): Promise<SummarizerInstance>;
}
interface SummarizerCreateOptions {
type?: "tldr" | "teaser" | "key-points" | "headline";
format?: "plain-text" | "markdown";
length?: "short" | "medium" | "long";
expectedInputLanguages?: string[];
outputLanguage?: string;
expectedContextLanguages?: string[];
sharedContext?: string;
monitor?: (model: unknown) => void;
}
interface SummarizerInstance {
summarize(text: string): Promise<string>;
}
declare global {
interface Window {
Summarizer?: SummarizerAPI;
}
}
export {};
Soooo now, we FINALLY can develop our application!
Application Development
Inside of the src folder we will have five folders:
- components
- hooks
- services
- store
- types
Let's start with the folder types. We can define our types before the implementation. Create this folder inside the src and create the file index.ts.
src/types/index.ts
export interface DevToArticleUser {
name: string;
username: string;
twitter_username: string | null;
github_username: string | null;
website_url: string | null;
profile_image: string;
profile_image_90: string;
}
export interface DevToArticleOrganization {
name: string;
username: string;
slug: string;
profile_image: string;
profile_image_90: string;
}
export interface ArticleType {
type_of: "article";
id: number;
title: string;
description: string;
cover_image: string;
readable_publish_date: string;
social_image: string;
tag_list: string[];
tags: string;
slug: string;
path: string;
url: string;
canonical_url: string;
comments_count: number;
positive_reactions_count: number;
public_reactions_count: number;
collection_id: number | null;
created_at: string;
edited_at: string | null;
crossposted_at: string | null;
published_at: string;
last_comment_at: string;
published_timestamp: string;
reading_time_minutes: number;
user: DevToArticleUser;
organization?: DevToArticleOrganization;
}
/** Article response from GET /api/articles/:id (single article by id) */
export interface ArticleByIdType {
type_of: "article";
id: number;
title: string;
description: string;
cover_image: string;
readable_publish_date: string;
social_image: string;
tag_list: string;
tags: string[];
slug: string;
path: string;
url: string;
canonical_url: string;
comments_count: number;
positive_reactions_count: number;
public_reactions_count: number;
collection_id: number | null;
created_at: string;
edited_at: string | null;
crossposted_at: string | null;
published_at: string;
last_comment_at: string;
published_timestamp: string;
reading_time_minutes: number;
body_html: string;
body_markdown: string;
user: DevToArticleUser;
organization?: DevToArticleOrganization;
}
Now we will create the folder services. So create this folder inside the src and create the file index.ts.
This file will be responsible to integrate with the Dev.to API's.
src/services/index.ts
import type { ArticleByIdType, ArticleType } from "../types";
export const fetchArticles = async (
quantity: number = 12,
username?: string,
): Promise<ArticleType[]> => {
const url = new URL("https://dev.to/api/articles");
if (quantity) url.searchParams.set("per_page", quantity.toString());
if (username) url.searchParams.set("username", username);
const response = await fetch(url.toString());
const data = await response.json();
return data;
};
export const fetchArticleById = async (
id: string,
): Promise<ArticleByIdType> => {
const response = await fetch(`https://dev.to/api/articles/${id}`);
const data = await response.json();
return data;
};
We will have two functions, first to fetch articles being possible to filter by quantity and author. The second function fetch a article by ID.
Now we going to create the store folder where we will use zustand to controll the state of our application. You might find it strange that I'm concentrating all the states in the zustand, but it will make sense when I implement WebMCP. So, yes, I won't use useState.
src/store/index.ts
import { create } from "zustand";
import type { ArticleType } from "../types";
interface ArticlesState {
articles: ArticleType[];
favorites: ArticleType[];
isSidebarOpen: boolean;
authorSearchInput: string;
articleIdToSummarize: string | number | null;
isOpenSummarizeModal: boolean;
setArticles: (articles: ArticleType[]) => void;
addFavorite: (article: ArticleType) => void;
removeFavorite: (article: ArticleType) => void;
clearFavorites: () => void;
setIsSidebarOpen: (isSidebarOpen: boolean) => void;
setAuthorSearchInput: (authorSearchInput: string) => void;
setArticleIdToSummarize: (
articleIdToSummarize: string | number | null,
) => void;
setIsOpenSummarizeModal: (isOpenSummarizeModal: boolean) => void;
}
export const useArticlesStore = create<ArticlesState>((set) => ({
articles: [],
favorites: [],
isSidebarOpen: false,
authorSearchInput: "",
articleIdToSummarize: null,
isOpenSummarizeModal: false,
setArticles: (articles) => set({ articles }),
addFavorite: (article) =>
set((state) => ({
favorites: state.favorites.some((a) => a.id === article.id)
? state.favorites
: [...state.favorites, article],
isSidebarOpen: true,
})),
removeFavorite: (article) =>
set((state) => ({
favorites: state.favorites.filter((a) => a.id !== article.id),
})),
clearFavorites: () => set({ favorites: [] }),
setIsSidebarOpen: (isSidebarOpen) => set({ isSidebarOpen }),
setAuthorSearchInput: (authorSearchInput) => set({ authorSearchInput }),
setArticleIdToSummarize: (articleIdToSummarize: string | number | null) =>
set({ articleIdToSummarize }),
setIsOpenSummarizeModal: (isOpenSummarizeModal: boolean) =>
set({ isOpenSummarizeModal }),
}));
Now the the folder hooks, we will have two files. First the webAI.ts and second useMCP.ts.
Let's start by webAI.ts:
src/hooks/webAI.ts
import type { ArticleByIdType } from "../types";
export const summarizeArticle = async (article: ArticleByIdType) => {
if ("Summarizer" in self) {
const availability = await self.Summarizer!.availability();
if (availability === "unavailable") {
console.error("Summarizer API is unavailable");
return;
}
const summarizer = await self.Summarizer!.create({
type: "tldr",
format: "plain-text",
length: "medium",
expectedInputLanguages: ["en", "ja", "es"],
outputLanguage: "en",
expectedContextLanguages: ["en"],
sharedContext:
"This is a article about the development programming (news, tutorials, etc.)",
monitor(m: any) {
m.addEventListener("downloadprogress", (e: any) => {
console.log(`Downloaded ${e.loaded * 100}%`);
});
},
});
const summary = await summarizer.summarize(article.body_markdown);
return summary;
}
};
If you don't know about the Web AI API's of chrome. You can find the documentation on Chrome Developers.
This API's use the Gemini Nano, so it will be downloaded on you chrome and you can use even offline.
Now the file a little complex to understand, but I promisse that I will try to you understood hahaha. Let's create the file useMcp.ts.
src/hooks/useMcp.ts
import { fetchArticleById, fetchArticles } from "../services";
import { useArticlesStore } from "../store";
export const useWebMcp = () => {
if (typeof navigator !== "undefined" && "modelContext" in navigator) {
(navigator as any).modelContext.provideContext({
tools: [
{
name: "handle_search_by_author",
description: "Search for articles by author.",
inputSchema: {
type: "object",
properties: {
username: {
type: "string",
default: "kevin-uehara",
description: "The username of the author to search for.",
},
},
},
execute: async ({ username }: { username?: string }) => {
const author = username ?? "kevin-uehara";
const results = await fetchArticles(12, author);
useArticlesStore.getState().setArticles(results);
return {
content: [
{
type: "text",
text:
results.length > 0
? `Found ${results.length} listings: ${JSON.stringify(results)}`
: "No listings found matching your criteria.",
},
],
};
},
},
{
name: "add_favorite",
description: "Add an article to the favorites list.",
inputSchema: {
type: "object",
properties: {
id: {
type: "array",
items: { type: "string" },
description:
"The id(s) of the article(s) to add to the favorites list.",
},
},
},
execute: async ({ id }: { id: string[] }) => {
const articleId = Array.isArray(id) ? id[0] : id;
if (!articleId)
return {
content: [
{ type: "text" as const, text: "No article id provided." },
],
};
const article = await fetchArticleById(articleId);
if (article) useArticlesStore.getState().addFavorite(article);
return {
content: [
{
type: "text",
text: article
? `Article added to favorites: ${JSON.stringify(article)}`
: "Article not found.",
},
],
};
},
},
{
name: "remove_article_from_favorites",
description: "Remove an article from the favorites list.",
inputSchema: {
type: "object",
properties: {
id: {
type: "array",
items: { type: "number" },
description:
"The id(s) of the article(s) to remove from the favorites list.",
},
title: {
type: "array",
items: { type: "string" },
description:
"The title(s) of the article(s) to remove from the favorites list.",
},
},
},
execute: async ({
id,
title,
}: {
id?: number[];
title?: string[];
}) => {
const firstId = Array.isArray(id) ? id[0] : undefined;
const firstTitle = Array.isArray(title) ? title[0] : undefined;
const article = useArticlesStore
.getState()
.favorites.find(
(a) => a.id === firstId || a.title === firstTitle,
);
if (article) {
useArticlesStore.getState().removeFavorite(article);
return {
content: [
{ type: "text", text: "Article removed from favorites." },
],
};
}
return {
content: [{ type: "text", text: "Article not found." }],
};
},
},
{
name: "get_favorites",
description: "Get the favorites list.",
inputSchema: { type: "object", properties: {} },
execute: async () => {
return {
content: [
{
type: "text",
text: JSON.stringify(useArticlesStore.getState().favorites),
},
],
};
},
},
{
name: "open_summarize_modal",
description: "Open the summarize modal.",
inputSchema: {
type: "object",
properties: {
articleId: {
type: "string",
description: "The id of the article to summarize.",
},
},
},
execute: async ({ articleId }: { articleId: string }) => {
useArticlesStore.getState().setArticleIdToSummarize(articleId);
useArticlesStore.getState().setIsOpenSummarizeModal(true);
return {
content: [{ type: "text", text: "Summarize modal opened." }],
};
},
},
{
name: "clear_favorites",
description: "Clear the favorites list.",
inputSchema: { type: "object", properties: {} },
execute: async () => {
useArticlesStore.getState().clearFavorites();
return {
content: [{ type: "text", text: "Favorites cleared." }],
};
},
},
],
});
}
};
YEAH, this file is giant. But let's undertand.
This file just are mapping all the tools that our application will provide to interact with our page.
So each object is a tool. If you're already familiar with the concept of MCP, this is nothing new.
Let's grab the easier tools to understand:
name: "get_favorites",
description: "Get the favorites list.",
inputSchema: { type: "object", properties: {} },
execute: async () => {
return {
content: [
{
type: "text",
text: JSON.stringify(useArticlesStore.getState().favorites),
},
],
};
},
}
This tool has a name get_favorites (it be unique), have a description (this is important for the AI understand what this tool do), the schema where can ben optional, but if you need some parameters, you need to declare here.
And now the execute that will receive a function (async or sync) and return in this same format above (with content array, where the object has the type text and the data).
Here is where I get from zustand the articles added on favorites and parse using JSON.stringify.
Another exemple:
Let's grab the tool to add_favorite.
{
name: "add_favorite",
description: "Add an article to the favorites list.",
inputSchema: {
type: "object",
properties: {
id: {
type: "array",
items: { type: "string" },
description:
"The id(s) of the article(s) to add to the favorites list.",
},
},
},
execute: async ({ id }: { id: string[] }) => {
const articleId = Array.isArray(id) ? id[0] : id;
if (!articleId)
return {
content: [
{ type: "text" as const, text: "No article id provided." },
],
};
const article = await fetchArticleById(articleId);
if (article) useArticlesStore.getState().addFavorite(article);
return {
content: [
{
type: "text",
text: article
? `Article added to favorites: ${JSON.stringify(article)}`
: "Article not found.",
},
],
};
},
}
Here is same thing, but now the tool is receiving the id as parameter and I add some description of this AI detect on prompt.
And now, on execute I'm validating the ID and calling the function fetchArticleById passing the ID. After that I call the zustand to add to favorite the article. Simple, isnβt ?
Now you can understand the other tools.
Finally, let's create our components:
We will have four components:
- ArticleCard
- FavoriteSidebar
- SummarizeModal
Starting with ArticleCard
src/components/ArticleCard/index.tsx
import type { ArticleType } from "../../types";
interface ArticleCardProps {
article: ArticleType;
onAddToFavorites?: (article: ArticleType) => void;
onOpenSummarizeModal?: (id: string | number) => void;
}
export function ArticleCard({
article,
onAddToFavorites,
onOpenSummarizeModal,
}: ArticleCardProps) {
const tags = article.tag_list;
return (
<article
className="flex flex-col bg-white rounded-xl overflow-hidden shadow-[0_2px_12px_rgba(0,0,0,0.08)] transition-all duration-200 ease-out hover:shadow-[0_8px_24px_rgba(0,0,0,0.12)] hover:-translate-y-0.5"
data-article-id={article.id}
>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="block leading-0"
>
<img
className="w-full h-[200px] object-cover block"
src={article.cover_image}
alt={article.title}
/>
</a>
<div className="flex flex-col flex-1 p-4 px-5">
<div className="flex items-center flex-wrap gap-2 mb-2 text-[0.8rem] text-gray-500">
<span
className="inline-flex items-center text-[0.7rem] font-semibold font-mono text-gray-500 bg-gray-200 px-2 py-0.5 rounded-md tracking-wide"
title="Article ID"
>
ID: {article.id}
</span>
<img
className="w-6 h-6 rounded-full object-cover"
src={article.user.profile_image_90 ?? article.user.profile_image}
alt={article.user.name}
/>
<span className="font-semibold text-gray-900">
{article.user.name}
</span>
<span className="text-gray-500">{article.readable_publish_date}</span>
<span className="text-gray-500">
{article.reading_time_minutes} min read
</span>
</div>
<h2 className="text-[1.1rem] font-semibold m-0 mb-2 text-gray-900 leading-tight line-clamp-2">
{article.title}
</h2>
<p className="text-sm text-gray-600 m-0 mb-3 leading-snug line-clamp-3">
{article.description}
</p>
{tags.length > 0 && (
<div className="flex flex-wrap gap-[0.35rem]">
{tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-1 bg-gray-200 text-gray-600 rounded-md"
>
{tag}
</span>
))}
</div>
)}
<div className="flex gap-4 mt-2 text-[0.85rem] text-gray-500">
<span className="inline-flex items-center gap-1">
β€οΈ {article.positive_reactions_count}
</span>
<span className="inline-flex items-center gap-1">
π¬ {article.comments_count}
</span>
</div>
<div className="mt-auto pt-4 flex flex-col gap-2">
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="inline-block w-full py-2.5 px-4 text-sm font-semibold text-center text-gray-900 bg-white border border-gray-300 rounded-lg no-underline transition-all duration-200 ease-out hover:bg-gray-100 hover:border-gray-400 hover:-translate-y-0.5 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] active:translate-y-0 active:scale-[0.98] active:shadow-[0_2px_6px_rgba(0,0,0,0.1)]"
>
Read article
</a>
<button
type="button"
onClick={() => onAddToFavorites?.(article)}
className="w-full py-2.5 px-4 text-sm font-semibold text-gray-900 bg-gray-100 border border-gray-300 rounded-lg cursor-pointer transition-all duration-200 ease-out hover:bg-gray-200 hover:border-gray-400 hover:-translate-y-0.5 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] active:translate-y-0 active:scale-[0.98] active:shadow-[0_2px_6px_rgba(0,0,0,0.1)]"
>
Add to favorites
</button>
<button
type="button"
onClick={() => onOpenSummarizeModal?.(article.id)}
className="w-full py-2.5 px-4 text-sm font-semibold text-gray-900 bg-gray-100 border border-gray-300 rounded-lg cursor-pointer transition-all duration-200 ease-out hover:bg-gray-200 hover:border-gray-400 hover:-translate-y-0.5 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] active:translate-y-0 active:scale-[0.98] active:shadow-[0_2px_6px_rgba(0,0,0,0.1)]"
>
Summarize with AI
</button>
</div>
</div>
</article>
);
}
Now the FavoriteSideBar:
src/components/FavoriteSideBar/index.tsx
import type { ArticleType } from "../../types";
interface FavoritesSidebarProps {
isOpen: boolean;
onClose: () => void;
favorites: ArticleType[];
onRemove: (article: ArticleType) => void;
}
export function FavoritesSidebar({
isOpen,
onClose,
favorites,
onRemove,
}: FavoritesSidebarProps) {
return (
<>
<div
role="button"
tabIndex={0}
aria-label="Close menu"
onClick={onClose}
onKeyDown={(e) => e.key === "Escape" && onClose()}
className={`fixed inset-0 bg-black/30 z-40 transition-opacity duration-200 ${
isOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
/>
<aside
className={`fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-xl z-50 flex flex-col transition-transform duration-300 ease-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<header className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<span className="text-2xl" aria-hidden>
β
</span>
Favorites {favorites.length > 0 && `(${favorites.length})`}
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close favorites"
className="p-2 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition-colors cursor-pointer"
>
<span className="text-xl leading-none">Γ</span>
</button>
</header>
<div className="flex-1 overflow-y-auto p-4">
{favorites.length === 0 ? (
<p className="text-gray-500 text-center py-8">
No articles in favorites. Click "Add to favorites" on a
card.
</p>
) : (
<ul className="space-y-4">
{favorites.map((article) => (
<li
key={article.id}
className="flex gap-3 p-3 rounded-xl bg-gray-50 border border-gray-100 hover:bg-gray-100/80 transition-colors"
>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden bg-gray-200"
>
<img
src={article.cover_image}
alt=""
className="w-full h-full object-cover"
/>
</a>
<div className="min-w-0 flex-1">
<h3 className="font-medium text-gray-900 text-sm line-clamp-2">
{article.title}
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{article.user.name} Β· {article.readable_publish_date}
</p>
<div className="mt-2 flex items-center gap-2">
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-blue-600 hover:underline"
>
Read
</a>
<button
type="button"
onClick={() => onRemove(article)}
className="text-xs font-medium text-red-600 hover:underline"
>
Remove
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</aside>
</>
);
}
Finally, the SummarizeModal:
src/components/SummarizeModal/index.tsx
import { useEffect, useState } from "react";
import { fetchArticleById } from "../../services";
import { summarizeArticle } from "../../hooks/webAI";
import type { ArticleByIdType } from "../../types";
interface SummarizeModalProps {
articleId: string | number | null;
onClose: () => void;
}
export function SummarizeModal({ articleId, onClose }: SummarizeModalProps) {
const [summary, setSummary] = useState<string | null>(null);
const [article, setArticle] = useState<ArticleByIdType | null>(null);
const [isLoadingSummary, setIsLoadingSummary] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const fetchArticleAndSummarize = async () => {
if (!articleId) return;
const articleResponse = await fetchArticleById(String(articleId));
setArticle(articleResponse);
setIsLoading(false);
const summaryResponse = await summarizeArticle(articleResponse);
setSummary(
summaryResponse ?? "It was not possible to summarize the article.",
);
setIsLoadingSummary(false);
};
useEffect(() => {
fetchArticleAndSummarize();
}, []);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal
aria-labelledby="summarize-modal-title"
>
<div
className="absolute inset-0 z-0 bg-black/60 backdrop-blur-sm"
aria-hidden
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-2xl max-h-[90vh] flex flex-col bg-white rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between shrink-0 px-6 py-4 border-b border-gray-200">
<h2
id="summarize-modal-title"
className="text-lg font-semibold text-gray-900"
>
Article details
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="p-2 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition-colors cursor-pointer"
>
<span className="text-xl leading-none">Γ</span>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{!isLoading && article && (
<article className="space-y-4">
{article.cover_image && (
<img
src={article.cover_image}
alt=""
className="w-full h-48 object-cover rounded-xl"
/>
)}
<div>
<h3 className="text-xl font-semibold text-gray-900 mb-1">
{article.title}
</h3>
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-500">
<img
className="w-8 h-8 rounded-full object-cover"
src={
article.user.profile_image_90 ??
article.user.profile_image
}
alt=""
/>
<span className="font-medium text-gray-700">
{article.user.name}
</span>
<span>Β·</span>
<span>{article.readable_publish_date}</span>
<span>Β·</span>
<span>{article.reading_time_minutes} min read</span>
</div>
</div>
{article.tags?.length > 0 && (
<div className="flex flex-wrap gap-2">
{article.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-1 bg-gray-200 text-gray-700 rounded-md"
>
{tag}
</span>
))}
</div>
)}
{isLoadingSummary && (
<div className="flex items-center justify-center py-12">
<span className="text-gray-500">Summarizing...</span>
</div>
)}
{summary && !isLoadingSummary && (
<div className="border-t border-gray-200 pt-4 mt-4 text-gray-700 text-sm leading-relaxed [&_a]:text-blue-600 [&_a]:underline [&_p]:mb-2">
<span className="font-bold">Summary:</span> {summary}
</div>
)}
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:underline"
>
Open article on Dev.to β
</a>
</article>
)}
</div>
</div>
</div>
);
}
To Finish, OMG!
Let's to bring together on App.tsx.
src/App.tsx
import { useEffect } from "react";
import { ArticleCard } from "./components/ArticleCard";
import { FavoritesSidebar } from "./components/FavoritesSidebar";
import { useArticlesStore } from "./store";
import { fetchArticles } from "./services";
import { useWebMcp } from "./hooks/webMcp";
import { SummarizeModal } from "./components/SummarizeModal";
function App() {
const {
articles,
favorites,
isSidebarOpen,
authorSearchInput,
isOpenSummarizeModal,
articleIdToSummarize,
setArticles,
setAuthorSearchInput,
addFavorite,
removeFavorite,
setIsSidebarOpen,
setArticleIdToSummarize,
setIsOpenSummarizeModal,
} = useArticlesStore();
useEffect(() => {
useWebMcp();
}, []);
useEffect(() => {
fetchArticles(12).then(setArticles);
}, []);
const handleSearchByAuthor = () => {
fetchArticles(12, authorSearchInput).then(setArticles);
};
return (
<>
<main className="min-h-screen bg-gray-100 py-8 px-4">
<div className="max-w-7xl mx-auto mb-6 flex flex-wrap items-center justify-center gap-3">
<label htmlFor="author-search" className="sr-only">
Search by author
</label>
<input
id="author-search"
type="search"
placeholder="Search by author..."
value={authorSearchInput}
onChange={(e) => setAuthorSearchInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearchByAuthor()}
className="w-64 sm:w-80 px-4 py-3 rounded-xl border border-gray-200 bg-white shadow-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300 focus:border-gray-300"
aria-label="Search by author"
/>
<button
type="button"
onClick={handleSearchByAuthor}
className="px-4 py-3 rounded-xl bg-gray-800 text-white font-medium hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors cursor-pointer"
>
Search
</button>
</div>
<button
type="button"
onClick={() => setIsSidebarOpen(true)}
aria-label="Open favorites"
className="fixed top-4 right-4 z-30 flex items-center gap-2 px-4 py-2.5 bg-white border border-gray-200 rounded-xl shadow-md hover:shadow-lg hover:bg-gray-50 transition-all duration-200"
>
<span className="text-2xs font-semibold" aria-hidden>
Favorites
</span>
{favorites.length > 0 && (
<span className="min-w-5 h-5 px-1.5 flex items-center justify-center text-xs font-semibold text-gray-700 bg-amber-100 rounded-full">
{favorites.length}
</span>
)}
</button>
<section
id="articles"
className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-6 max-w-7xl mx-auto"
>
{articles.map((article) => (
<ArticleCard
key={article.id}
article={article}
onAddToFavorites={addFavorite}
onOpenSummarizeModal={() => {
setArticleIdToSummarize(article.id);
setIsOpenSummarizeModal(true);
}}
/>
))}
</section>
</main>
<FavoritesSidebar
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
favorites={favorites}
onRemove={removeFavorite}
/>
{isOpenSummarizeModal && articleIdToSummarize && (
<SummarizeModal
articleId={articleIdToSummarize}
onClose={() => setIsOpenSummarizeModal(false)}
/>
)}
</>
);
}
export default App;
Note, that I'm not using the useState and I'm controlling every state by Zustand. Because I decided to separate the WebMCP in another file. Some people is using the WebMCP tools in the same file of the component or on App.tsx. But you can decide how to implement.
The Result
The Result with WebMCP
Conclusion
π Congratulations! You've successfully built your first application using WebMCP and WebAI Integrated.
What You've Accomplished:
β
Created a functional WebMCP Tools
β
Integrated With Web AI API's of Chrome
β
Integrated on Web Application
β
Learned the fundamentals of WebMCP
π Resources and Further Reading
APIs Used in This Tutorial
Dev.TO API
Chrome Developers
WebMCP Announcement
Demo Repo
Thank you very much for reading this far and stay well always!
Contacts:
Linkedin: https://www.linkedin.com/in/kevin-uehara/
Instagram: https://www.instagram.com/uehara_kevin/
Twitter: https://x.com/ueharaDev
Github: https://github.com/kevinuehara
dev.to: https://dev.to/kevin-uehara
Youtube: https://www.youtube.com/@ueharakevin/




Top comments (0)