DEV Community

Cover image for WebMCP and WebAI: Exploring native AI tools in Chrome
Kevin Toshihiro Uehara
Kevin Toshihiro Uehara

Posted on

WebMCP and WebAI: Exploring native AI tools in Chrome

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 API and WebMCP for testing.

Project Setup

pnpm create vite
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()],
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 {};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 }),
}));
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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." }],
            };
          },
        },
      ],
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

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),
                },
              ],
            };
          },
        }
Enter fullscreen mode Exit fullscreen mode

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.",
                },
              ],
            };
          },
        }
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 &quot;Add to favorites&quot; 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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

Application Result

The Result with WebMCP

WebMCP to Favorites

ImagWebMCP to Summarize

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

Demo available on GitHub

Thank you very much for reading this far and stay well always!

Thats all folks

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)