Skip to content
Merged
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
57 changes: 57 additions & 0 deletions public/app/data/iframe-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* 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 {
const payload = await executeSearch(params.arguments);
transport.send({ jsonrpc: "2.0", id, result: payload });
Comment thread
ryan-roemer marked this conversation as resolved.
Outdated
} 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");
};
28 changes: 23 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 = { ...opts, device: "webgpu" };
Comment thread
ryan-roemer marked this conversation as resolved.
Outdated
}
} catch {
/* fall through to WASM */
}
}

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

/**
* Get embeddings data for a given chunk size.
Expand Down Expand Up @@ -106,6 +119,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 +135,7 @@ export const searchPosts = async ({
normalize: true,
});
const queryEmbedding = Array.from(queryExtracted.data);
queryExtracted.dispose?.(); // free up memory aggressively (espcially for wasm).
Comment thread
ryan-roemer marked this conversation as resolved.
Outdated
const embeddingTime = embeddingTimer.end();

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

const chunkLimit = Math.min(maxChunks || MAX_CHUNKS, MAX_CHUNKS);
Comment thread
ryan-roemer marked this conversation as resolved.
Outdated
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>