281 lines
8.9 KiB
TypeScript
281 lines
8.9 KiB
TypeScript
import { logger } from "../logger";
|
|
import { tools } from "./tools";
|
|
|
|
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:4005/api";
|
|
const API_TIMEOUT = Number(process.env.API_TIMEOUT) || 180000;
|
|
|
|
async function callMarketplaceApi(
|
|
marketplace: string,
|
|
params: URLSearchParams,
|
|
): Promise<unknown> {
|
|
const url = `${API_BASE_URL}/${marketplace}?${params.toString()}`;
|
|
logger.log(`[MCP] Calling ${marketplace} API`);
|
|
const response = await Promise.race([
|
|
fetch(url),
|
|
new Promise<Response>((_, reject) =>
|
|
setTimeout(
|
|
() => reject(new Error(`Request timed out after ${API_TIMEOUT}ms`)),
|
|
API_TIMEOUT,
|
|
),
|
|
),
|
|
]);
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
logger.error(
|
|
`[MCP] ${marketplace} API error ${response.status}: ${errorText}`,
|
|
);
|
|
throw new Error(`API returned ${response.status}: ${errorText}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Handle MCP JSON-RPC 2.0 protocol requests
|
|
*/
|
|
export async function handleMcpRequest(req: Request): Promise<Response> {
|
|
try {
|
|
const body = await req.json();
|
|
|
|
// Validate JSON-RPC 2.0 format
|
|
if (!body.jsonrpc || body.jsonrpc !== "2.0" || !body.method) {
|
|
return Response.json(
|
|
{
|
|
jsonrpc: "2.0",
|
|
error: { code: -32600, message: "Invalid Request" },
|
|
id: body.id,
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const { method, params, id } = body;
|
|
|
|
// Handle initialize method
|
|
if (method === "initialize") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: {
|
|
tools: {
|
|
listChanged: true,
|
|
},
|
|
},
|
|
serverInfo: {
|
|
name: "marketplace-scrapers",
|
|
version: "1.0.0",
|
|
},
|
|
instructions:
|
|
"Use search_kijiji, search_facebook, or search_ebay tools to find listings across Canadian marketplaces",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Handle tools/list method
|
|
if (method === "tools/list") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: {
|
|
tools,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Handle notifications (messages without id field should not get a response)
|
|
if (!id) {
|
|
// Notifications don't require a response
|
|
if (method === "notifications/initialized") {
|
|
// Client initialized successfully, no response needed
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
if (method === "notifications/progress") {
|
|
// Progress notifications, no response needed
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
// Unknown notification - still no response for notifications
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
|
|
// Handle tools/call method
|
|
if (method === "tools/call") {
|
|
const { name, arguments: args } = params || {};
|
|
|
|
if (!name || !args) {
|
|
return Response.json(
|
|
{
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: {
|
|
code: -32602,
|
|
message: "Invalid params: name and arguments required",
|
|
},
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Route tool calls to appropriate handlers
|
|
try {
|
|
let result: unknown;
|
|
|
|
if (name === "search_kijiji") {
|
|
const query = args.query;
|
|
if (!query) {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: { code: -32602, message: "query parameter is required" },
|
|
});
|
|
}
|
|
const params = new URLSearchParams({ q: query });
|
|
if (args.location) params.append("location", args.location);
|
|
if (args.category) params.append("category", args.category);
|
|
if (args.keywords) params.append("keywords", args.keywords);
|
|
if (args.sortBy) params.append("sortBy", args.sortBy);
|
|
if (args.sortOrder) params.append("sortOrder", args.sortOrder);
|
|
if (args.maxPages)
|
|
params.append("maxPages", args.maxPages.toString());
|
|
if (args.priceMin)
|
|
params.append("priceMin", args.priceMin.toString());
|
|
if (args.priceMax)
|
|
params.append("priceMax", args.priceMax.toString());
|
|
if (args.unstableFilter !== undefined)
|
|
params.append("unstableFilter", args.unstableFilter.toString());
|
|
|
|
logger.log(
|
|
`[MCP] Calling Kijiji API: ${API_BASE_URL}/kijiji?${params.toString()}`,
|
|
);
|
|
const response = await Promise.race([
|
|
fetch(`${API_BASE_URL}/kijiji?${params.toString()}`),
|
|
new Promise<Response>((_, reject) =>
|
|
setTimeout(
|
|
() =>
|
|
reject(new Error(`Request timed out after ${API_TIMEOUT}ms`)),
|
|
API_TIMEOUT,
|
|
),
|
|
),
|
|
]);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
logger.error(
|
|
`[MCP] Kijiji API error ${response.status}: ${errorText}`,
|
|
);
|
|
let errorMessage = `API returned ${response.status}: ${errorText}`;
|
|
try {
|
|
const errorJson = JSON.parse(errorText) as { message?: string };
|
|
if (errorJson.message) errorMessage = errorJson.message;
|
|
} catch {
|
|
// not JSON — use raw text
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
result = await response.json();
|
|
logger.log(
|
|
`[MCP] Kijiji returned ${Array.isArray(result) ? result.length : 0} items`,
|
|
);
|
|
} else if (name === "search_facebook") {
|
|
const query = args.query;
|
|
if (!query) {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: { code: -32602, message: "query parameter is required" },
|
|
});
|
|
}
|
|
const params = new URLSearchParams({ q: query });
|
|
if (args.location) params.append("location", args.location);
|
|
if (args.maxItems)
|
|
params.append("maxItems", args.maxItems.toString());
|
|
if (args.unstableFilter !== undefined)
|
|
params.append("unstableFilter", args.unstableFilter.toString());
|
|
|
|
result = await callMarketplaceApi("facebook", params);
|
|
} else if (name === "search_ebay") {
|
|
const query = args.query;
|
|
if (!query) {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: { code: -32602, message: "query parameter is required" },
|
|
});
|
|
}
|
|
const params = new URLSearchParams({ q: query });
|
|
if (args.minPrice)
|
|
params.append("minPrice", args.minPrice.toString());
|
|
if (args.maxPrice)
|
|
params.append("maxPrice", args.maxPrice.toString());
|
|
if (args.strictMode !== undefined)
|
|
params.append("strictMode", args.strictMode.toString());
|
|
if (args.exclusions?.length)
|
|
params.append("exclusions", args.exclusions.join(","));
|
|
if (args.keywords?.length)
|
|
params.append("keywords", args.keywords.join(","));
|
|
if (args.buyItNowOnly !== undefined)
|
|
params.append("buyItNowOnly", args.buyItNowOnly.toString());
|
|
if (args.canadaOnly !== undefined)
|
|
params.append("canadaOnly", args.canadaOnly.toString());
|
|
if (args.maxItems)
|
|
params.append("maxItems", args.maxItems.toString());
|
|
if (args.unstableFilter !== undefined)
|
|
params.append("unstableFilter", args.unstableFilter.toString());
|
|
|
|
result = await callMarketplaceApi("ebay", params);
|
|
} else {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: { code: -32601, message: `Unknown tool: ${name}` },
|
|
});
|
|
}
|
|
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(result, null, 2),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Unknown error";
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: {
|
|
code: -32603,
|
|
message: `Tool execution failed: ${errorMessage}`,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Method not found
|
|
return Response.json(
|
|
{
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: { code: -32601, message: `Method not found: ${method}` },
|
|
},
|
|
{ status: 404 },
|
|
);
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Unknown error";
|
|
return Response.json(
|
|
{
|
|
jsonrpc: "2.0",
|
|
error: { code: -32700, message: `Parse error: ${errorMessage}` },
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
}
|