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
Binary file removed .playwright-mcp/vector-search-demo.png
Binary file not shown.
398 changes: 105 additions & 293 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
"format": "npm run lint:fix && npm run pretty:fix"
},
"devDependencies": {
"eslint": "^9.39.2",
"@eslint/js": "^10.0.1",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"llm-splitter": "^0.2.0",
"prettier": "^3.7.4"
"prettier": "^3.8.1"
}
}
4 changes: 2 additions & 2 deletions public/app/components/forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ const CATEGORY_OPTIONS = CATEGORIES_LIST.map((category) => ({
value: category,
}));

const POST_TYPE_OPTIONS = [
{ label: "Services", value: "service" },
export const POST_TYPE_OPTIONS = [
// { label: "Services", value: "service" },
{ label: "Work", value: "work" },
{ label: "Blogs", value: "blog" },
];
Expand Down
67 changes: 67 additions & 0 deletions public/app/data/iframe-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* global window:false, URLSearchParams:false */
import { IframeChildTransport } from "@mcp-b/transports";
import { TOOL_SCHEMA, executeSearch } from "./tool-defs.js";

const DEBUG = new URLSearchParams(window.location.search).has("debug");
const log = (level, ...args) => {
if (DEBUG) console[level]("[iframe-server]", ...args); // eslint-disable-line no-undef
};

export const initIframeServer = () => {
if (window === window.parent) return;

const transport = new IframeChildTransport({
allowedOrigins: [
"http://localhost:4610",
"http://127.0.0.1:4610",
"https://nearform.github.io",
],
});

transport.onmessage = async (message) => {
const { jsonrpc, id, method, params } = message;
if (jsonrpc !== "2.0" || id == null) return;

if (method === "tools/list") {
transport.send({ jsonrpc: "2.0", id, result: { tools: [TOOL_SCHEMA] } });
return;
}

if (method === "tools/call") {
try {
// MCP tools/call result must contain a `content` array and optional `isError` flag.
// See: https://modelcontextprotocol.io/specification/2025-06-18/server/tools
// Format: { content: [{ type: "text", text: "..." }], isError: false }
const payload = await executeSearch(params.arguments);
transport.send({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
isError: false,
},
});
} catch (err) {
transport.send({
jsonrpc: "2.0",
id,
error: { code: -32000, message: err.message },
});
}
return;
}

transport.send({
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method not found: ${method}` },
});
};

transport.onerror = (err) => {
log("warn", "Transport error:", err);
};

transport.start();
log("log", "MCP transport started");
};
29 changes: 24 additions & 5 deletions public/app/data/search.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* global navigator:false */
import { create, insertMultiple, search } from "@orama/orama";
import { pipeline } from "@xenova/transformers";
import { getChunk } from "llm-splitter";
Expand All @@ -21,9 +22,21 @@ const dateToNumber = (date) => Date.parse(date);
* Downloads the model on first call (~30MB).
* @returns {Promise<Function>} Transformers pipeline
*/
export const getExtractor = getAndCache(() =>
pipeline("feature-extraction", EMBEDDING_MODEL),
);
export const getExtractor = getAndCache(async () => {
let opts;
if ("gpu" in navigator) {
try {
const adapter = await navigator.gpu.requestAdapter();
if (adapter) {
opts = { device: "webgpu" };
}
} catch {
/* fall through to WASM */
}
}

return pipeline("feature-extraction", EMBEDDING_MODEL, opts);
});

/**
* Get embeddings data for a given chunk size.
Expand Down Expand Up @@ -98,6 +111,7 @@ export const getChunksDb = getAndCache(async (chunkSize = 256) => {
* @param {string[]} [params.postType] - Filter by post types
* @param {string} [params.minDate] - Filter by minimum date (YYYY-MM-DD)
* @param {string[]} [params.categoryPrimary] - Filter by primary categories
* @param {number} [params.maxChunks] - Maximum number of chunks to return (default 50, max 50)
* @returns {Promise<{posts: Object[], chunks: Array, metadata: Object}>}
*/
export const searchPosts = async ({
Expand All @@ -106,6 +120,7 @@ export const searchPosts = async ({
postType,
minDate,
categoryPrimary,
maxChunks,
Comment thread
ryan-roemer marked this conversation as resolved.
}) => {
const [chunksDb, extractor, postsData, chunksData] = await Promise.all([
getChunksDb(chunkSize),
Expand All @@ -121,6 +136,7 @@ export const searchPosts = async ({
normalize: true,
});
const queryEmbedding = Array.from(queryExtracted.data);
queryExtracted.dispose?.(); // free up memory aggressively (especially for wasm).
const embeddingTime = embeddingTimer.end();

// Build where clause for filtering
Expand Down Expand Up @@ -211,18 +227,21 @@ export const searchPosts = async ({
}
: { min: 0, max: 0, avg: 0 };

const chunkLimit = Math.min(Math.max(maxChunks ?? MAX_CHUNKS, 1), MAX_CHUNKS);
const limitedChunks = chunksArray.slice(0, chunkLimit);

return {
metadata: {
elapsed: {
embedding: embeddingTime,
search: searchTime,
},
chunks: {
count: chunksArray.length,
count: limitedChunks.length,
similarity: similarityStats,
},
},
posts,
chunks: chunksArray,
chunks: limitedChunks,
};
};
68 changes: 68 additions & 0 deletions public/app/data/tool-defs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { searchPosts } from "./search.js";
import { POST_TYPE_OPTIONS } from "../components/forms.js";
import { CATEGORIES_LIST } from "../components/category.js";

const POST_TYPE_VALUES = POST_TYPE_OPTIONS.map((o) => o.value);

export const TOOL_SCHEMA = {
name: "search_nearform_knowledge",
description:
"Vector search across Nearform blog posts and case studies using semantic similarity. Returns matching posts with titles, URLs, dates, and similarity scores.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query (e.g. 'microservices architecture')",
},
postType: {
type: "array",
items: { type: "string", enum: POST_TYPE_VALUES },
description: `Filter by type: ${POST_TYPE_VALUES.join(", ")}. ALMOST NEVER USE THIS. Only set when user literally asks for 'case studies' (work) or 'blog posts' (blog). Omit for all other queries.`,
},
minDate: {
type: "string",
description: "Only posts after this date, YYYY-MM-DD (optional)",
},
categoryPrimary: {
type: "array",
items: { type: "string", enum: CATEGORIES_LIST },
description: `Filter by category: ${CATEGORIES_LIST.join(", ")} (optional)`,
},
maxChunks: {
type: "number",
description: "Maximum number of chunks to return (default 50, max 50)",
},
},
required: ["query"],
},
};

export const executeSearch = async (args) => {
const result = await searchPosts({
query: args.query,
postType: args.postType || [],
minDate: args.minDate || "",
categoryPrimary: args.categoryPrimary || [],
chunkSize: 256,
maxChunks: args.maxChunks,
});
return {
postCount: result.posts.length,
posts: result.posts.map((p) => ({
slug: p.slug,
title: p.title,
href: p.href,
date: p.date,
type: p.postType,
categories: p.categories,
similarity: p.similarityMax,
})),
chunks: result.chunks.map((c) => ({
slug: c.slug,
text: c.text,
similarity: c.similarity,
})),
metadata: result.metadata,
};
};
39 changes: 39 additions & 0 deletions public/app/data/webmcp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* global navigator:false */
import { TOOL_SCHEMA, executeSearch } from "./tool-defs.js";

const checkWebMcpSupport = () => {
if ("modelContext" in navigator) return true;

const { warn } = console; // eslint-disable-line no-undef
const BLOG_URL = "https://developer.chrome.com/blog/webmcp-epp";
const chromeMatch = navigator.userAgent.match(/Chrome\/(\d+)/);
const chromeVersion = chromeMatch ? parseInt(chromeMatch[1], 10) : null;

if (!chromeMatch) {
warn(`WebMCP requires Chrome 146+. See ${BLOG_URL}`);
} else if (chromeVersion < 146) {
warn(
`WebMCP requires Chrome 146+ (you have ${chromeVersion}). See ${BLOG_URL}`,
);
} else {
warn(
`WebMCP not enabled. Go to chrome://flags, search "WebMCP", enable "WebMCP for testing", and relaunch Chrome. See ${BLOG_URL}`,
);
}
return false;
};

export const registerWebMcpTools = () => {
if (!checkWebMcpSupport()) return;

navigator.modelContext.registerTool({
...TOOL_SCHEMA,
annotations: { readOnlyHint: true },
execute: async (input) => {
const payload = await executeSearch(input);
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
};
},
});
};
35 changes: 26 additions & 9 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,21 @@
<script type="importmap">
{
"imports": {
"@orama/orama": "https://esm.sh/@orama/orama@3.1.18?dev",
"@orama/orama": "https://cdn.jsdelivr.net/npm/@orama/orama@3.1.18/+esm",
"@xenova/transformers": "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2",
"htm": "https://esm.sh/htm@3.1.1?dev&external=react,react-dom",
"llm-splitter": "https://esm.sh/llm-splitter@@0.2.0?dev",
"react": "https://esm.sh/react@19.2.3?dev&external=react,react-dom",
"react/jsx-runtime": "https://esm.sh/react@19.2.3/jsx-runtime?dev&external=react,react-dom",
"react-dom": "https://esm.sh/react-dom@19.2.3?dev&external=react,react-dom",
"react-dom/client": "https://esm.sh/react-dom@19.2.3/client?dev&external=react,react-dom",
"react-is": "https://esm.sh/react-is@19.2.3?dev&external=react,react-dom",
"react-select": "https://esm.sh/react-select@5.10.2?dev&external=react,react-dom"
"htm": "https://cdn.jsdelivr.net/npm/htm@3.1.1/+esm",
"llm-splitter": "https://cdn.jsdelivr.net/npm/llm-splitter@0.2.0/+esm",
"react": "https://cdn.jsdelivr.net/npm/react@19.2.3/+esm",
"react/jsx-runtime": "https://cdn.jsdelivr.net/npm/react@19.2.3/jsx-runtime/+esm",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom@19.2.3/+esm",
"react-dom/client": "https://cdn.jsdelivr.net/npm/react-dom@19.2.3/client/+esm",
"react-is": "https://cdn.jsdelivr.net/npm/react-is@19.2.3/+esm",
"react-select": "https://cdn.jsdelivr.net/npm/react-select@5.10.2/+esm",
"@mcp-b/transports": "https://cdn.jsdelivr.net/npm/@mcp-b/transports@2.1.0/+esm",

"https://cdn.jsdelivr.net/npm/react@19.0.0/": "https://cdn.jsdelivr.net/npm/react@19.2.3/",
"https://cdn.jsdelivr.net/npm/react@19.1.0/": "https://cdn.jsdelivr.net/npm/react@19.2.3/",
"https://cdn.jsdelivr.net/npm/react-dom@19.1.0/": "https://cdn.jsdelivr.net/npm/react-dom@19.2.3/"
}
}
</script>
Expand All @@ -74,5 +79,17 @@
const root = createRoot(document.getElementById("root"));
root.render(html`<${App} />`);
</script>
<script type="module">
const { registerWebMcpTools } = await import("./app/data/webmcp.js");
registerWebMcpTools();
</script>
<script type="module">
// If loaded inside an iframe, start the MCP iframe transport server
if (window !== window.parent) {
const { initIframeServer } =
await import("./app/data/iframe-server.js");
initIframeServer();
}
</script>
</body>
</html>