=> Boolean(post));
+
+ return [
+ {
+ name: "get_next_meetup",
+ title: "Get the next Kyoto Tech Meetup",
+ description: "Find the next upcoming Kyoto Tech Meetup and its RSVP and map links.",
+ inputSchema: emptyInputSchema,
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
+ execute: () => ({ event: events[0] ?? null }),
+ },
+ {
+ name: "list_upcoming_meetups",
+ title: "List upcoming Kyoto Tech Meetups",
+ description: "List a bounded number of upcoming Kyoto Tech Meetup events.",
+ inputSchema: {
+ type: "object",
+ properties: { limit: limitSchema },
+ additionalProperties: false,
+ },
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
+ execute: (input) => ({ events: events.slice(0, boundedLimit(input.limit)) }),
+ },
+ {
+ name: "get_event_details",
+ title: "Get meetup event details",
+ description: "Get the details and links for one event returned by the meetup tools.",
+ inputSchema: {
+ type: "object",
+ properties: { eventId: { type: "string", minLength: 1 } },
+ required: ["eventId"],
+ additionalProperties: false,
+ },
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
+ execute: (input) => ({ event: events.find((event) => event.id === input.eventId) ?? null }),
+ },
+ {
+ name: "get_community_links",
+ title: "Get Kyoto Tech Meetup community links",
+ description: "Get the official Meetup, Discord, GitHub, LinkedIn, contact, and calendar links.",
+ inputSchema: emptyInputSchema,
+ annotations: { readOnlyHint: true, untrustedContentHint: false },
+ execute: () => ({ links: data.links }),
+ },
+ {
+ name: "list_member_posts",
+ title: "List member publications",
+ description: "List recent items from the homepage's What members are publishing section.",
+ inputSchema: {
+ type: "object",
+ properties: { limit: limitSchema, source: { type: "string", maxLength: 100 } },
+ additionalProperties: false,
+ },
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
+ execute: (input) => {
+ const source = typeof input.source === "string" ? input.source.toLowerCase() : null;
+ const filtered = source ? posts.filter((post) => post.sourceName.toLowerCase() === source) : posts;
+ return { posts: filtered.slice(0, boundedLimit(input.limit)) };
+ },
+ },
+ {
+ name: "get_member_post",
+ title: "Get a member publication",
+ description: "Get one published member item from the homepage feed by its stable ID.",
+ inputSchema: {
+ type: "object",
+ properties: { postId: { type: "string", minLength: 1 } },
+ required: ["postId"],
+ additionalProperties: false,
+ },
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
+ execute: (input) => ({ post: posts.find((post) => post.id === input.postId) ?? null }),
+ },
+ {
+ name: "search_member_posts",
+ title: "Search member publications",
+ description: "Search the published member items shown on the homepage by title, summary, or source.",
+ inputSchema: {
+ type: "object",
+ properties: { query: { type: "string", minLength: 1, maxLength: 200 }, limit: limitSchema },
+ required: ["query"],
+ additionalProperties: false,
+ },
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
+ execute: (input) => {
+ const query = typeof input.query === "string" ? input.query.trim().toLowerCase() : "";
+ if (!query) return { posts: [] };
+ const matches = posts.filter((post) =>
+ [post.title, post.summary, post.sourceName].some((value) => value.toLowerCase().includes(query)),
+ );
+ return { posts: matches.slice(0, boundedLimit(input.limit)) };
+ },
+ },
+ ];
+}
+
+export async function registerWebMcpTools(
+ data: WebMcpData,
+ documentLike: DocumentLike | undefined = globalThis.document as unknown as DocumentLike,
+ navigatorLike: NavigatorLike | undefined = globalThis.navigator as unknown as NavigatorLike,
+): Promise<"registerTool" | "provideContext" | false> {
+ const tools = createWebMcpTools(data);
+ const modernContext = documentLike?.modelContext;
+ if (modernContext?.registerTool) {
+ for (const tool of tools) await modernContext.registerTool(tool);
+ return "registerTool";
+ }
+
+ const legacyContext = navigatorLike?.modelContext;
+ if (legacyContext?.provideContext) {
+ legacyContext.provideContext({ tools });
+ return "provideContext";
+ }
+
+ return false;
+}
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 4380b66..105276b 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -19,6 +19,7 @@ import {
selectUpcomingMeetupEvents,
} from "../lib/meetup-events";
import { getMemberMilestone } from "../lib/member-milestone";
+import WebMcpProvider from "../components/WebMcpProvider.astro";
import { getSafeWebUrl } from "../lib/safe-url";
const { lang = "en" } = Astro.props;
@@ -169,6 +170,33 @@ const feedsWithItems = memberFeedList.map((feed) => {
};
});
+const webMcpData = {
+ events: events.map((event) => ({
+ ...event,
+ description: "",
+ image: null,
+ })),
+ memberPosts: feedsWithItems.flatMap((feed) =>
+ feed.items.map((item) => ({
+ id: item.id,
+ title: item.title,
+ link: item.link,
+ publishedAt: item.publishedAt,
+ summary: item.summary,
+ sourceName: feed.name,
+ sourceUrl: feed.siteUrl ?? undefined,
+ })),
+ ),
+ links: {
+ meetup: meetupUrl,
+ discord: discordUrl,
+ github: githubUrl,
+ linkedin: linkedinUrl,
+ contact: contactUrl,
+ calendar: "#calendar",
+ },
+};
+
const formatDate = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return null;
@@ -188,6 +216,8 @@ const formatDate = (value: string) => {
nextEventUrl={nextEventUrl}
/>
+
+
{
+ test("exposes the read-only event and published-content footprint", async () => {
+ const tools = createWebMcpTools(data);
+ expect(tools.map((tool) => tool.name)).toEqual([
+ "get_next_meetup",
+ "list_upcoming_meetups",
+ "get_event_details",
+ "get_community_links",
+ "list_member_posts",
+ "get_member_post",
+ "search_member_posts",
+ ]);
+ expect(tools.every((tool) => tool.annotations.readOnlyHint)).toBe(true);
+ expect(tools.find((tool) => tool.name === "get_next_meetup").execute({})).toMatchObject({
+ event: { title: "Next meetup", rsvpUrl: data.events[0].link },
+ });
+ expect(tools.find((tool) => tool.name === "search_member_posts").execute({ query: "article" })).toMatchObject({
+ posts: [{ id: "post-1" }],
+ });
+ });
+
+ test("registers modern and legacy browser APIs, and fails closed", async () => {
+ const registrations = [];
+ const modern = { modelContext: { registerTool: async (tool) => registrations.push(tool.name) } };
+ expect(await registerWebMcpTools(data, modern, undefined)).toBe("registerTool");
+ expect(registrations).toHaveLength(7);
+
+ let legacyTools;
+ const legacy = { modelContext: { provideContext: (context) => { legacyTools = context.tools; } } };
+ expect(await registerWebMcpTools(data, undefined, legacy)).toBe("provideContext");
+ expect(legacyTools).toHaveLength(7);
+ expect(await registerWebMcpTools(data, undefined, undefined)).toBe(false);
+ });
+});