Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/components/WebMcpProvider.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
import { serializeJsonLd } from "../lib/structured-data";
import type { WebMcpData } from "../lib/webmcp-tools";

type Props = {
data: WebMcpData;
};

const { data } = Astro.props as Props;
const serializedData = serializeJsonLd(data);
---

<script id="webmcp-data" type="application/json" is:inline set:html={serializedData}></script>
<script>
import { registerWebMcpTools } from "../lib/webmcp-tools";

const dataElement = document.getElementById("webmcp-data");
if (dataElement?.textContent) {
try {
const data = JSON.parse(dataElement.textContent);
void registerWebMcpTools(data);
} catch (error) {
console.warn("WebMCP data could not be registered.", error);
}
}
</script>
212 changes: 212 additions & 0 deletions src/lib/webmcp-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { buildMeetupVenueMapsUrl, normalizeMeetupEventTitle, type MeetupEvent } from "./meetup-events";
import { getSafeWebUrl } from "./safe-url";

export type WebMcpMemberPost = {
id: string;
title: string;
link: string;
publishedAt: string;
summary?: string;
sourceName: string;
sourceUrl?: string;
};

export type WebMcpData = {
events: MeetupEvent[];
memberPosts: WebMcpMemberPost[];
links: Record<string, string>;
};

type Tool = {
name: string;
title: string;
description: string;
inputSchema: Record<string, unknown>;
annotations: { readOnlyHint: true; untrustedContentHint: boolean };
// eslint-disable-next-line no-unused-vars
execute: (input: Record<string, unknown>) => unknown;
};

type DocumentLike = {
// eslint-disable-next-line no-unused-vars
modelContext?: { registerTool?: (tool: Tool) => Promise<unknown> };
};

type NavigatorLike = {
// eslint-disable-next-line no-unused-vars
modelContext?: { provideContext?: (context: { tools: Tool[] }) => unknown };
};

const emptyInputSchema = {
type: "object",
properties: {},
additionalProperties: false,
};

const limitSchema = {
type: "integer",
minimum: 1,
maximum: 10,
default: 5,
};

function boundedLimit(value: unknown, fallback = 5): number {
if (typeof value !== "number" || !Number.isInteger(value)) return fallback;
return Math.min(10, Math.max(1, value));
}

function safeEvent(event: MeetupEvent) {
const link = getSafeWebUrl(event.link);
if (!link) return null;
const mapsUrl = buildMeetupVenueMapsUrl(event.venue);
return {
id: link,
title: normalizeMeetupEventTitle(event.title),
start: event.start,
endTime: event.endTime,
timezone: "Asia/Tokyo",
venue: event.venue,
goingCount: event.goingCount,
interestedCount: event.interestedCount,
eventType: event.eventType,
rsvpUrl: link,
mapsUrl,
};
}

function safePost(post: WebMcpMemberPost) {
const link = getSafeWebUrl(post.link);
if (!link) return null;
return {
id: post.id || link,
title: post.title,
link,
publishedAt: post.publishedAt,
summary: post.summary ?? "",
sourceName: post.sourceName,
sourceUrl: getSafeWebUrl(post.sourceUrl),
};
}

export function createWebMcpTools(data: WebMcpData): Tool[] {
const events = data.events
.map(safeEvent)
.filter((event): event is NonNullable<typeof event> => Boolean(event));
const posts = data.memberPosts
.map(safePost)
.filter((post): post is NonNullable<typeof post> => 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;
}
30 changes: 30 additions & 0 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
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;
Expand Down Expand Up @@ -169,6 +170,33 @@
};
});

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;
Expand All @@ -188,6 +216,8 @@
nextEventUrl={nextEventUrl}
/>

<WebMcpProvider data={webMcpData} />

<div>
<Hero
event={nextEvent}
Expand Down Expand Up @@ -499,7 +529,7 @@
class="text-2xl font-semibold"
>
{t("home.communityFeed.contribute.title")}
</h3>

Check warning on line 532 in src/pages/index.astro

View workflow job for this annotation

GitHub Actions / build

File has too many lines (521). Maximum allowed is 500
<a
href={memberFeedsEditUrl}
target="_blank"
Expand Down
66 changes: 66 additions & 0 deletions test/webmcp-tools.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, test } from "vitest";
import { createWebMcpTools, registerWebMcpTools } from "../src/lib/webmcp-tools.ts";

const data = {
events: [
{
title: "Next meetup",
link: "https://www.meetup.com/kyoto-tech-meetup/events/123/",
start: "2026-07-20T09:00:00+09:00",
endTime: null,
description: "Event description",
image: null,
goingCount: 4,
interestedCount: 10,
eventType: "coffee",
venue: { name: "Kyoto Cafe", city: "Kyoto", country: "JP" },
},
],
memberPosts: [
{
id: "post-1",
title: "Published post",
link: "https://example.com/post",
publishedAt: "2026-07-10T00:00:00Z",
summary: "A useful article",
sourceName: "Member One",
sourceUrl: "https://example.com/",
},
],
links: { meetup: "https://www.meetup.com/kyoto-tech-meetup/" },
};

describe("WebMCP tools", () => {
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);
});
});
Loading