MCP directory metadata, resources/prompts handlers, full param descriptions (#260)
This commit is contained in:
parent
0525513b0c
commit
e41b52ca56
@ -38,22 +38,53 @@ const marketSchema = z
|
|||||||
.object({
|
.object({
|
||||||
country: z
|
country: z
|
||||||
.enum(["US", "USA", "United States", "United States of America"])
|
.enum(["US", "USA", "United States", "United States of America"])
|
||||||
.optional(),
|
.optional()
|
||||||
|
.describe("Country selector. Only the United States is supported."),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Optional United States market object. Defaults to United States.");
|
.describe("Optional United States market object. Defaults to United States.");
|
||||||
|
|
||||||
const nearSchema = z.object({
|
const nearSchema = z
|
||||||
latitude: z.number().min(-90).max(90),
|
.object({
|
||||||
longitude: z.number().min(-180).max(180),
|
latitude: z
|
||||||
radiusKm: z.number().min(1).max(100000),
|
.number()
|
||||||
});
|
.min(-90)
|
||||||
|
.max(90)
|
||||||
|
.describe("Latitude of the search center."),
|
||||||
|
longitude: z
|
||||||
|
.number()
|
||||||
|
.min(-180)
|
||||||
|
.max(180)
|
||||||
|
.describe("Longitude of the search center."),
|
||||||
|
radiusKm: z
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.max(100000)
|
||||||
|
.describe("Search radius around the center, in kilometers."),
|
||||||
|
})
|
||||||
|
.describe("Coordinate and radius to search around.");
|
||||||
|
|
||||||
const localSerpNearSchema = z.object({
|
const localSerpNearSchema = z
|
||||||
latitude: z.number().min(-90).max(90),
|
.object({
|
||||||
longitude: z.number().min(-180).max(180),
|
latitude: z
|
||||||
zoom: z.number().int().min(4).max(18).optional(),
|
.number()
|
||||||
});
|
.min(-90)
|
||||||
|
.max(90)
|
||||||
|
.describe("Latitude the SERP is fetched from."),
|
||||||
|
longitude: z
|
||||||
|
.number()
|
||||||
|
.min(-180)
|
||||||
|
.max(180)
|
||||||
|
.describe("Longitude the SERP is fetched from."),
|
||||||
|
zoom: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(4)
|
||||||
|
.max(18)
|
||||||
|
.optional()
|
||||||
|
.describe("Map zoom level (4-18). Higher zoom narrows the local area."),
|
||||||
|
})
|
||||||
|
.describe("Coordinate (and optional map zoom) the SERP is fetched from.");
|
||||||
|
|
||||||
const domainTargetSchema = z
|
const domainTargetSchema = z
|
||||||
.string()
|
.string()
|
||||||
@ -80,64 +111,176 @@ const rankedTargetSchema = z
|
|||||||
|
|
||||||
const getRankedKeywordsInputSchema = {
|
const getRankedKeywordsInputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
target: rankedTargetSchema,
|
target: rankedTargetSchema.describe(
|
||||||
|
"Domain (no protocol/www) or absolute page URL to list ranked keywords for.",
|
||||||
|
),
|
||||||
market: marketSchema,
|
market: marketSchema,
|
||||||
resultTypes: z.array(rankedResultTypeSchema).min(1).max(5).optional(),
|
resultTypes: z
|
||||||
includeSubdomains: z.boolean().optional(),
|
.array(rankedResultTypeSchema)
|
||||||
minSearchVolume: z.number().int().min(0).optional(),
|
.min(1)
|
||||||
maxRank: z.number().int().min(1).max(100).optional(),
|
.max(5)
|
||||||
|
.optional()
|
||||||
|
.describe("SERP result types to include. Defaults to organic and paid."),
|
||||||
|
includeSubdomains: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Include subdomains of the target. Defaults to true for domains, false for page URLs.",
|
||||||
|
),
|
||||||
|
minSearchVolume: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.describe("Only return keywords with at least this monthly search volume."),
|
||||||
|
maxRank: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe("Only return keywords ranking at this position or better."),
|
||||||
excludeBrandTerms: z
|
excludeBrandTerms: z
|
||||||
.array(z.string().min(1).max(80))
|
.array(z.string().min(1).max(80))
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(10)
|
.max(10)
|
||||||
.optional(),
|
.optional()
|
||||||
|
.describe("Exclude keywords containing any of these brand terms."),
|
||||||
sortBy: z
|
sortBy: z
|
||||||
.enum(["rank", "search_volume", "traffic_estimate", "cpc"])
|
.enum(["rank", "search_volume", "traffic_estimate", "cpc"])
|
||||||
.optional(),
|
.optional()
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
.describe("Sort order for returned rows. Defaults to search_volume."),
|
||||||
offset: z.number().int().min(0).max(1000).optional(),
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe("Maximum rows to return (1-100). Defaults to 50."),
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(1000)
|
||||||
|
.optional()
|
||||||
|
.describe("Rows to skip for pagination."),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const searchLocalBusinessesInputSchema = {
|
const searchLocalBusinessesInputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
query: z.string().min(1).max(200).optional(),
|
query: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.describe("Business name or title text to match."),
|
||||||
near: nearSchema,
|
near: nearSchema,
|
||||||
categories: z.array(z.string().min(1).max(120)).min(1).max(10).optional(),
|
categories: z
|
||||||
limit: z.number().int().min(1).max(50).optional(),
|
.array(z.string().min(1).max(120))
|
||||||
|
.min(1)
|
||||||
|
.max(10)
|
||||||
|
.optional()
|
||||||
|
.describe("Business categories to filter by (e.g. 'pizza_restaurant')."),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.optional()
|
||||||
|
.describe("Maximum businesses to return (1-50). Defaults to 20."),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const localSearchTypeSchema = z.enum(["maps", "local_finder"]);
|
const localSearchTypeSchema = z.enum(["maps", "local_finder"]);
|
||||||
|
|
||||||
const getLocalSerpResultsInputSchema = {
|
const getLocalSerpResultsInputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
keyword: z.string().min(1).max(120),
|
keyword: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(120)
|
||||||
|
.describe("Search query to run on Google Maps or Local Finder."),
|
||||||
near: localSerpNearSchema,
|
near: localSerpNearSchema,
|
||||||
searchType: localSearchTypeSchema.optional(),
|
searchType: localSearchTypeSchema
|
||||||
device: z.enum(["desktop", "mobile"]).optional(),
|
.optional()
|
||||||
depth: z.number().int().min(1).max(100).optional(),
|
.describe("Which local SERP to fetch. Defaults to maps."),
|
||||||
|
device: z
|
||||||
|
.enum(["desktop", "mobile"])
|
||||||
|
.optional()
|
||||||
|
.describe("Device the SERP is rendered for. Defaults to desktop."),
|
||||||
|
depth: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe("Number of results to fetch (1-100). Defaults to 20."),
|
||||||
languageCode: languageCodeSchema.optional(),
|
languageCode: languageCodeSchema.optional(),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const getGoogleBusinessQuestionsInputSchema = {
|
const getGoogleBusinessQuestionsInputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
keyword: z.string().min(1).max(200),
|
keyword: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.describe(
|
||||||
|
"Business name or search phrase identifying the Google Business Profile.",
|
||||||
|
),
|
||||||
near: nearSchema,
|
near: nearSchema,
|
||||||
depth: z.number().int().min(1).max(100).optional(),
|
depth: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe("Maximum Q&A rows to fetch (1-100). Defaults to 20."),
|
||||||
languageCode: languageCodeSchema.optional(),
|
languageCode: languageCodeSchema.optional(),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const findSerpCompetitorsInputSchema = {
|
const findSerpCompetitorsInputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
keywords: z.array(z.string().min(1).max(120)).min(1).max(100),
|
keywords: z
|
||||||
|
.array(z.string().min(1).max(120))
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.describe("Keywords whose SERPs are compared (1-100)."),
|
||||||
market: marketSchema,
|
market: marketSchema,
|
||||||
resultTypes: z.array(serpCompetitorResultTypeSchema).min(1).max(4).optional(),
|
resultTypes: z
|
||||||
excludeDomains: z.array(domainTargetSchema).min(1).max(50).optional(),
|
.array(serpCompetitorResultTypeSchema)
|
||||||
includeSubdomains: z.boolean().optional(),
|
.min(1)
|
||||||
|
.max(4)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"SERP result types to include. Defaults to organic and local_pack.",
|
||||||
|
),
|
||||||
|
excludeDomains: z
|
||||||
|
.array(domainTargetSchema)
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.optional()
|
||||||
|
.describe("Domains to exclude from results (e.g. the user's own site)."),
|
||||||
|
includeSubdomains: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Count subdomains as part of the same competitor domain."),
|
||||||
sortBy: z
|
sortBy: z
|
||||||
.enum(["visibility", "traffic_estimate", "avg_position", "keyword_count"])
|
.enum(["visibility", "traffic_estimate", "avg_position", "keyword_count"])
|
||||||
.optional(),
|
.optional()
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
.describe("Sort order for returned competitors. Defaults to visibility."),
|
||||||
offset: z.number().int().min(0).max(1000).optional(),
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe("Maximum competitors to return (1-100). Defaults to 50."),
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(1000)
|
||||||
|
.optional()
|
||||||
|
.describe("Rows to skip for pagination."),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const keywordMetricsSortSchema = z.enum([
|
const keywordMetricsSortSchema = z.enum([
|
||||||
@ -149,11 +292,20 @@ const keywordMetricsSortSchema = z.enum([
|
|||||||
|
|
||||||
const getKeywordMetricsInputSchema = {
|
const getKeywordMetricsInputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
keywords: z.array(z.string().min(1).max(80)).min(1).max(700),
|
keywords: z
|
||||||
|
.array(z.string().min(1).max(80))
|
||||||
|
.min(1)
|
||||||
|
.max(700)
|
||||||
|
.describe("Keywords to fetch metrics for (1-700)."),
|
||||||
locationCode: locationCodeSchema.optional(),
|
locationCode: locationCodeSchema.optional(),
|
||||||
languageCode: languageCodeSchema.optional(),
|
languageCode: languageCodeSchema.optional(),
|
||||||
includeMonthlyTrends: z.boolean().optional(),
|
includeMonthlyTrends: z
|
||||||
sortBy: keywordMetricsSortSchema.optional(),
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Include monthly search-volume trend rows. Defaults to true."),
|
||||||
|
sortBy: keywordMetricsSortSchema
|
||||||
|
.optional()
|
||||||
|
.describe("Sort order for returned rows. Defaults to search_volume."),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Market = z.infer<typeof marketSchema>;
|
type Market = z.infer<typeof marketSchema>;
|
||||||
@ -346,7 +498,7 @@ export const getRankedKeywordsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Get ranked keywords",
|
title: "Get ranked keywords",
|
||||||
description:
|
description:
|
||||||
"Returns exact keyword, URL, rank, search volume, CPC, intent, and traffic rows for a domain or page. Use this for strategy evidence; use get_domain_overview for aggregate domain footprint. Charges DataForSEO Labs credits.",
|
"Returns exact keyword, URL, rank, search volume, CPC, intent, and traffic rows for a domain or page. Use this for strategy evidence; use get_domain_overview for aggregate domain footprint. Charges credits.",
|
||||||
inputSchema: getRankedKeywordsInputSchema,
|
inputSchema: getRankedKeywordsInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
keywords: z.array(looseObjectOutputSchema),
|
keywords: z.array(looseObjectOutputSchema),
|
||||||
@ -405,7 +557,7 @@ export const searchLocalBusinessesTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Search local businesses",
|
title: "Search local businesses",
|
||||||
description:
|
description:
|
||||||
"Searches DataForSEO Business Listings near a coordinate. Use this to find local business candidates or nearby competitors; it does not run Maps rank checks or Q&A. Charges DataForSEO Business Data credits.",
|
"Searches local business listings near a coordinate. Use this to find local business candidates or nearby competitors; it does not run Maps rank checks or Q&A. Charges credits.",
|
||||||
inputSchema: searchLocalBusinessesInputSchema,
|
inputSchema: searchLocalBusinessesInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
businesses: z.array(looseObjectOutputSchema),
|
businesses: z.array(looseObjectOutputSchema),
|
||||||
@ -441,7 +593,7 @@ export const getLocalSerpResultsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Get local SERP results",
|
title: "Get local SERP results",
|
||||||
description:
|
description:
|
||||||
"Fetches one Google Maps or Local Finder SERP near a coordinate. Returns provider rows with rank fields intact; callers decide how to match a target business. Charges DataForSEO SERP credits.",
|
"Fetches one Google Maps or Local Finder SERP near a coordinate. Returns provider rows with rank fields intact; callers decide how to match a target business. Charges credits.",
|
||||||
inputSchema: getLocalSerpResultsInputSchema,
|
inputSchema: getLocalSerpResultsInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
results: z.array(looseObjectOutputSchema),
|
results: z.array(looseObjectOutputSchema),
|
||||||
@ -480,7 +632,7 @@ export const getGoogleBusinessQuestionsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Get Google business questions",
|
title: "Get Google business questions",
|
||||||
description:
|
description:
|
||||||
"Fetches Google Business Profile questions and answers for one business keyword near a coordinate. Run this only when Q&A evidence is needed. Charges DataForSEO Business Data credits.",
|
"Fetches Google Business Profile questions and answers for one business keyword near a coordinate. Run this only when Q&A evidence is needed. Charges credits.",
|
||||||
inputSchema: getGoogleBusinessQuestionsInputSchema,
|
inputSchema: getGoogleBusinessQuestionsInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
questions: z.array(looseObjectOutputSchema),
|
questions: z.array(looseObjectOutputSchema),
|
||||||
@ -516,7 +668,7 @@ export const findSerpCompetitorsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Find SERP competitors",
|
title: "Find SERP competitors",
|
||||||
description:
|
description:
|
||||||
"Compares domains competing for a supplied keyword set using DataForSEO Labs SERP Competitors. Useful for market and search-intelligence reports; not radius-based local SEO. Charges DataForSEO Labs credits.",
|
"Compares domains competing in Google results for a supplied keyword set. Useful for market and search-intelligence reports; not radius-based local SEO. Charges credits.",
|
||||||
inputSchema: findSerpCompetitorsInputSchema,
|
inputSchema: findSerpCompetitorsInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
competitors: z.array(looseObjectOutputSchema),
|
competitors: z.array(looseObjectOutputSchema),
|
||||||
@ -570,7 +722,7 @@ export const getKeywordMetricsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Get keyword metrics",
|
title: "Get keyword metrics",
|
||||||
description:
|
description:
|
||||||
"Hydrate up to 700 known keywords with search volume, keyword difficulty (KD), search intent, CPC, competition, and monthly trends in a single call. Use it to score candidate or known keywords — including Search Console striking-distance queries — by real demand and ranking difficulty. Charges DataForSEO Labs credits.",
|
"Hydrate up to 700 known keywords with search volume, keyword difficulty (KD), search intent, CPC, competition, and monthly trends in a single call. Use it to score candidate or known keywords — including Search Console striking-distance queries — by real demand and ranking difficulty. Charges credits.",
|
||||||
inputSchema: getKeywordMetricsInputSchema,
|
inputSchema: getKeywordMetricsInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
keywords: z.array(looseObjectOutputSchema),
|
keywords: z.array(looseObjectOutputSchema),
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export const getBacklinksOverviewTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Get backlinks overview",
|
title: "Get backlinks overview",
|
||||||
description:
|
description:
|
||||||
"Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~200-500 typical). Requires that the user's DataForSEO account has Backlinks enabled.",
|
"Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~200-500 typical). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.",
|
||||||
inputSchema,
|
inputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
overview: looseObjectOutputSchema,
|
overview: looseObjectOutputSchema,
|
||||||
|
|||||||
@ -15,7 +15,11 @@ import {
|
|||||||
const inputSchema = {
|
const inputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
domain: z.string().min(1).describe("Domain to analyze (e.g. 'example.com')."),
|
domain: z.string().min(1).describe("Domain to analyze (e.g. 'example.com')."),
|
||||||
includeSubdomains: z.boolean().optional().default(false),
|
includeSubdomains: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe("Include subdomains in the domain's metrics. Defaults to false."),
|
||||||
locationCode: locationCodeSchema.optional(),
|
locationCode: locationCodeSchema.optional(),
|
||||||
languageCode: languageCodeSchema.optional(),
|
languageCode: languageCodeSchema.optional(),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export const getRankTrackerTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Get rank tracker",
|
title: "Get rank tracker",
|
||||||
description:
|
description:
|
||||||
"Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Free — reads from OpenSEO state, no DataForSEO call. To trigger a new check, use the dashboard.",
|
"Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Free — reads from OpenSEO state, charges no credits. To trigger a new check, use the dashboard.",
|
||||||
inputSchema,
|
inputSchema,
|
||||||
outputSchema: z
|
outputSchema: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/server/mcp/schemas";
|
} from "@/server/mcp/schemas";
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
keyword: z.string().min(1),
|
keyword: z.string().min(1).describe("Search query to fetch the SERP for."),
|
||||||
locationCode: locationCodeSchema.optional(),
|
locationCode: locationCodeSchema.optional(),
|
||||||
languageCode: languageCodeSchema.optional(),
|
languageCode: languageCodeSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export const listProjectsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "List projects",
|
title: "List projects",
|
||||||
description:
|
description:
|
||||||
"Lists all projects in the user's organization. Free — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.",
|
"Lists all projects in the user's organization. Free — charges no credits. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.",
|
||||||
inputSchema: {} as Record<string, never>,
|
inputSchema: {} as Record<string, never>,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
projects: z.array(
|
projects: z.array(
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export const listSavedKeywordsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "List saved keywords",
|
title: "List saved keywords",
|
||||||
description:
|
description:
|
||||||
"Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC, and tags if available). Free — reads from OpenSEO's database, no DataForSEO call. Use tag filters when the user asks for a saved segment; multiple tags match ANY tag.",
|
"Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC, and tags if available). Free — reads from OpenSEO's database, charges no credits. Use tag filters when the user asks for a saved segment; multiple tags match ANY tag.",
|
||||||
inputSchema,
|
inputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
rows: z.array(looseObjectOutputSchema),
|
rows: z.array(looseObjectOutputSchema),
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export const saveKeywordsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Save keywords",
|
title: "Save keywords",
|
||||||
description:
|
description:
|
||||||
"Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op. If tags are provided, missing tags may be created. By default tags are appended; set tagMode=replace to remove existing tags from these saved keywords before applying the provided tags, which is useful for reorganizing keywords into page/topic clusters. Ask the user for confirmation before applying or replacing tags broadly.",
|
"Save keywords to a project's saved-keywords list. Free — charges no credits. Idempotent: re-saving an existing keyword is a no-op. If tags are provided, missing tags may be created. By default tags are appended; set tagMode=replace to remove existing tags from these saved keywords before applying the provided tags, which is useful for reorganizing keywords into page/topic clusters. Ask the user for confirmation before applying or replacing tags broadly.",
|
||||||
inputSchema,
|
inputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const whoamiTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Who am I",
|
title: "Who am I",
|
||||||
description:
|
description:
|
||||||
"Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Free — does not call DataForSEO. Use this first to confirm connection context before choosing a project or running paid tools.",
|
"Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Free — charges no credits. Use this first to confirm connection context before choosing a project or running paid tools.",
|
||||||
inputSchema: {} as Record<string, never>,
|
inputSchema: {} as Record<string, never>,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import type { CreateMcpHandlerOptions } from "agents/mcp";
|
import type { CreateMcpHandlerOptions } from "agents/mcp";
|
||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||||
@ -12,6 +14,7 @@ const selfHostedAuthMocks = vi.hoisted(() => ({
|
|||||||
const serverMocks = vi.hoisted(() => ({
|
const serverMocks = vi.hoisted(() => ({
|
||||||
nextServerId: 0,
|
nextServerId: 0,
|
||||||
serverIds: new WeakMap<McpServer, number>(),
|
serverIds: new WeakMap<McpServer, number>(),
|
||||||
|
lastServer: undefined as McpServer | undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
|
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
|
||||||
@ -31,6 +34,7 @@ vi.mock("agents/mcp", () => ({
|
|||||||
createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => {
|
createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => {
|
||||||
serverMocks.nextServerId += 1;
|
serverMocks.nextServerId += 1;
|
||||||
serverMocks.serverIds.set(_server, serverMocks.nextServerId);
|
serverMocks.serverIds.set(_server, serverMocks.nextServerId);
|
||||||
|
serverMocks.lastServer = _server;
|
||||||
|
|
||||||
return async () =>
|
return async () =>
|
||||||
new Response(
|
new Response(
|
||||||
@ -85,6 +89,7 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
serverMocks.nextServerId = 0;
|
serverMocks.nextServerId = 0;
|
||||||
serverMocks.serverIds = new WeakMap<McpServer, number>();
|
serverMocks.serverIds = new WeakMap<McpServer, number>();
|
||||||
|
serverMocks.lastServer = undefined;
|
||||||
selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({
|
selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({
|
||||||
userId: "local-admin",
|
userId: "local-admin",
|
||||||
userEmail: "admin@localhost",
|
userEmail: "admin@localhost",
|
||||||
@ -173,4 +178,46 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
|
|||||||
).not.toHaveBeenCalled();
|
).not.toHaveBeenCalled();
|
||||||
expect(body.options.authContext).toBeUndefined();
|
expect(body.options.authContext).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Directory scanners (e.g. Smithery) read server metadata from initialize.
|
||||||
|
it("serves directory metadata in the initialize response", async () => {
|
||||||
|
const { handleSelfHostedOpenSeoMcpRequest } =
|
||||||
|
await import("@/server/mcp/transport");
|
||||||
|
|
||||||
|
await handleSelfHostedOpenSeoMcpRequest(
|
||||||
|
createMcpRequest(),
|
||||||
|
"local_noauth",
|
||||||
|
{},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const server = serverMocks.lastServer;
|
||||||
|
if (!server) throw new Error("MCP server was not created");
|
||||||
|
|
||||||
|
const [clientTransport, serverTransport] =
|
||||||
|
InMemoryTransport.createLinkedPair();
|
||||||
|
const client = new Client({ name: "test-client", version: "0.0.0" });
|
||||||
|
await Promise.all([
|
||||||
|
client.connect(clientTransport),
|
||||||
|
server.connect(serverTransport),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serverInfo = client.getServerVersion();
|
||||||
|
expect(serverInfo).toMatchObject({
|
||||||
|
name: "OpenSEO MCP",
|
||||||
|
title: "OpenSEO",
|
||||||
|
websiteUrl: "https://openseo.so",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "https://openseo.so/android-chrome-512x512.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
sizes: ["512x512"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(serverInfo?.description).toContain(
|
||||||
|
"SEO research tools for AI agents",
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,7 +17,18 @@ function createOpenSeoMcpServer() {
|
|||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
{
|
{
|
||||||
name: "OpenSEO MCP",
|
name: "OpenSEO MCP",
|
||||||
version: "0.0.10",
|
title: "OpenSEO",
|
||||||
|
version: "0.0.11",
|
||||||
|
description:
|
||||||
|
"SEO research tools for AI agents: keyword research and metrics, SERP and local SERP results, domain and backlink analysis, rank tracking, and Google Search Console performance.",
|
||||||
|
websiteUrl: "https://openseo.so",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "https://openseo.so/android-chrome-512x512.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
sizes: ["512x512"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
instructions:
|
instructions:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user