From e41b52ca56069d1870dff408332329f939df97a0 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:11:24 -0400 Subject: [PATCH] MCP directory metadata, resources/prompts handlers, full param descriptions (#260) --- .../mcp/tools/dataforseo-research-tools.ts | 242 ++++++++++++++---- .../mcp/tools/get-backlinks-overview.ts | 2 +- src/server/mcp/tools/get-domain-overview.ts | 6 +- src/server/mcp/tools/get-rank-tracker.ts | 2 +- src/server/mcp/tools/get-serp-results.ts | 2 +- src/server/mcp/tools/list-projects.ts | 2 +- src/server/mcp/tools/list-saved-keywords.ts | 2 +- src/server/mcp/tools/save-keywords.ts | 2 +- src/server/mcp/tools/whoami.ts | 2 +- src/server/mcp/transport.test.ts | 47 ++++ src/server/mcp/transport.ts | 13 +- 11 files changed, 268 insertions(+), 54 deletions(-) diff --git a/src/server/mcp/tools/dataforseo-research-tools.ts b/src/server/mcp/tools/dataforseo-research-tools.ts index 2880125..dba9a57 100644 --- a/src/server/mcp/tools/dataforseo-research-tools.ts +++ b/src/server/mcp/tools/dataforseo-research-tools.ts @@ -38,22 +38,53 @@ const marketSchema = z .object({ country: z .enum(["US", "USA", "United States", "United States of America"]) - .optional(), + .optional() + .describe("Country selector. Only the United States is supported."), }) .optional() .describe("Optional United States market object. Defaults to United States."); -const nearSchema = z.object({ - latitude: z.number().min(-90).max(90), - longitude: z.number().min(-180).max(180), - radiusKm: z.number().min(1).max(100000), -}); +const nearSchema = z + .object({ + latitude: z + .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({ - latitude: z.number().min(-90).max(90), - longitude: z.number().min(-180).max(180), - zoom: z.number().int().min(4).max(18).optional(), -}); +const localSerpNearSchema = z + .object({ + latitude: z + .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 .string() @@ -80,64 +111,176 @@ const rankedTargetSchema = z const getRankedKeywordsInputSchema = { projectId: projectIdSchema, - target: rankedTargetSchema, + target: rankedTargetSchema.describe( + "Domain (no protocol/www) or absolute page URL to list ranked keywords for.", + ), market: marketSchema, - resultTypes: z.array(rankedResultTypeSchema).min(1).max(5).optional(), - includeSubdomains: z.boolean().optional(), - minSearchVolume: z.number().int().min(0).optional(), - maxRank: z.number().int().min(1).max(100).optional(), + resultTypes: z + .array(rankedResultTypeSchema) + .min(1) + .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 .array(z.string().min(1).max(80)) .min(1) .max(10) - .optional(), + .optional() + .describe("Exclude keywords containing any of these brand terms."), sortBy: z .enum(["rank", "search_volume", "traffic_estimate", "cpc"]) - .optional(), - limit: z.number().int().min(1).max(100).optional(), - offset: z.number().int().min(0).max(1000).optional(), + .optional() + .describe("Sort order for returned rows. Defaults to search_volume."), + 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; const searchLocalBusinessesInputSchema = { 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, - categories: z.array(z.string().min(1).max(120)).min(1).max(10).optional(), - limit: z.number().int().min(1).max(50).optional(), + categories: z + .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; const localSearchTypeSchema = z.enum(["maps", "local_finder"]); const getLocalSerpResultsInputSchema = { 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, - searchType: localSearchTypeSchema.optional(), - device: z.enum(["desktop", "mobile"]).optional(), - depth: z.number().int().min(1).max(100).optional(), + searchType: localSearchTypeSchema + .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(), } as const; const getGoogleBusinessQuestionsInputSchema = { 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, - 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(), } as const; const findSerpCompetitorsInputSchema = { 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, - resultTypes: z.array(serpCompetitorResultTypeSchema).min(1).max(4).optional(), - excludeDomains: z.array(domainTargetSchema).min(1).max(50).optional(), - includeSubdomains: z.boolean().optional(), + resultTypes: z + .array(serpCompetitorResultTypeSchema) + .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 .enum(["visibility", "traffic_estimate", "avg_position", "keyword_count"]) - .optional(), - limit: z.number().int().min(1).max(100).optional(), - offset: z.number().int().min(0).max(1000).optional(), + .optional() + .describe("Sort order for returned competitors. Defaults to visibility."), + 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; const keywordMetricsSortSchema = z.enum([ @@ -149,11 +292,20 @@ const keywordMetricsSortSchema = z.enum([ const getKeywordMetricsInputSchema = { 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(), languageCode: languageCodeSchema.optional(), - includeMonthlyTrends: z.boolean().optional(), - sortBy: keywordMetricsSortSchema.optional(), + includeMonthlyTrends: z + .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; type Market = z.infer; @@ -346,7 +498,7 @@ export const getRankedKeywordsTool = { config: { title: "Get ranked keywords", 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, outputSchema: { keywords: z.array(looseObjectOutputSchema), @@ -405,7 +557,7 @@ export const searchLocalBusinessesTool = { config: { title: "Search local businesses", 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, outputSchema: { businesses: z.array(looseObjectOutputSchema), @@ -441,7 +593,7 @@ export const getLocalSerpResultsTool = { config: { title: "Get local SERP results", 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, outputSchema: { results: z.array(looseObjectOutputSchema), @@ -480,7 +632,7 @@ export const getGoogleBusinessQuestionsTool = { config: { title: "Get Google business questions", 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, outputSchema: { questions: z.array(looseObjectOutputSchema), @@ -516,7 +668,7 @@ export const findSerpCompetitorsTool = { config: { title: "Find SERP competitors", 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, outputSchema: { competitors: z.array(looseObjectOutputSchema), @@ -570,7 +722,7 @@ export const getKeywordMetricsTool = { config: { title: "Get keyword metrics", 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, outputSchema: { keywords: z.array(looseObjectOutputSchema), diff --git a/src/server/mcp/tools/get-backlinks-overview.ts b/src/server/mcp/tools/get-backlinks-overview.ts index 02df2f5..e50cf5c 100644 --- a/src/server/mcp/tools/get-backlinks-overview.ts +++ b/src/server/mcp/tools/get-backlinks-overview.ts @@ -40,7 +40,7 @@ export const getBacklinksOverviewTool = { config: { title: "Get backlinks overview", 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, outputSchema: { overview: looseObjectOutputSchema, diff --git a/src/server/mcp/tools/get-domain-overview.ts b/src/server/mcp/tools/get-domain-overview.ts index 1d4fb38..f2be86f 100644 --- a/src/server/mcp/tools/get-domain-overview.ts +++ b/src/server/mcp/tools/get-domain-overview.ts @@ -15,7 +15,11 @@ import { const inputSchema = { projectId: projectIdSchema, 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(), languageCode: languageCodeSchema.optional(), } as const; diff --git a/src/server/mcp/tools/get-rank-tracker.ts b/src/server/mcp/tools/get-rank-tracker.ts index d9cd70b..0ab3c87 100644 --- a/src/server/mcp/tools/get-rank-tracker.ts +++ b/src/server/mcp/tools/get-rank-tracker.ts @@ -27,7 +27,7 @@ export const getRankTrackerTool = { config: { title: "Get rank tracker", 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, outputSchema: z .object({ diff --git a/src/server/mcp/tools/get-serp-results.ts b/src/server/mcp/tools/get-serp-results.ts index 767dc39..5661d68 100644 --- a/src/server/mcp/tools/get-serp-results.ts +++ b/src/server/mcp/tools/get-serp-results.ts @@ -13,7 +13,7 @@ import { } from "@/server/mcp/schemas"; 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(), languageCode: languageCodeSchema.optional(), }); diff --git a/src/server/mcp/tools/list-projects.ts b/src/server/mcp/tools/list-projects.ts index f407959..8af01dd 100644 --- a/src/server/mcp/tools/list-projects.ts +++ b/src/server/mcp/tools/list-projects.ts @@ -13,7 +13,7 @@ export const listProjectsTool = { config: { title: "List projects", 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, outputSchema: { projects: z.array( diff --git a/src/server/mcp/tools/list-saved-keywords.ts b/src/server/mcp/tools/list-saved-keywords.ts index ca4cf39..4fad7d0 100644 --- a/src/server/mcp/tools/list-saved-keywords.ts +++ b/src/server/mcp/tools/list-saved-keywords.ts @@ -33,7 +33,7 @@ export const listSavedKeywordsTool = { config: { title: "List saved keywords", 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, outputSchema: { rows: z.array(looseObjectOutputSchema), diff --git a/src/server/mcp/tools/save-keywords.ts b/src/server/mcp/tools/save-keywords.ts index 7c3553b..ab0805f 100644 --- a/src/server/mcp/tools/save-keywords.ts +++ b/src/server/mcp/tools/save-keywords.ts @@ -43,7 +43,7 @@ export const saveKeywordsTool = { config: { title: "Save keywords", 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, outputSchema: { projectId: z.string(), diff --git a/src/server/mcp/tools/whoami.ts b/src/server/mcp/tools/whoami.ts index 0cc9eb1..181db9f 100644 --- a/src/server/mcp/tools/whoami.ts +++ b/src/server/mcp/tools/whoami.ts @@ -23,7 +23,7 @@ export const whoamiTool = { config: { title: "Who am I", 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, outputSchema: { userId: z.string(), diff --git a/src/server/mcp/transport.test.ts b/src/server/mcp/transport.test.ts index 4266b5f..23cf363 100644 --- a/src/server/mcp/transport.test.ts +++ b/src/server/mcp/transport.test.ts @@ -1,5 +1,7 @@ import type { CreateMcpHandlerOptions } from "agents/mcp"; 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 { z } from "zod"; import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; @@ -12,6 +14,7 @@ const selfHostedAuthMocks = vi.hoisted(() => ({ const serverMocks = vi.hoisted(() => ({ nextServerId: 0, serverIds: new WeakMap(), + lastServer: undefined as McpServer | undefined, })); vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({ @@ -31,6 +34,7 @@ vi.mock("agents/mcp", () => ({ createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => { serverMocks.nextServerId += 1; serverMocks.serverIds.set(_server, serverMocks.nextServerId); + serverMocks.lastServer = _server; return async () => new Response( @@ -85,6 +89,7 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => { vi.clearAllMocks(); serverMocks.nextServerId = 0; serverMocks.serverIds = new WeakMap(); + serverMocks.lastServer = undefined; selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({ userId: "local-admin", userEmail: "admin@localhost", @@ -173,4 +178,46 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => { ).not.toHaveBeenCalled(); 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(); + }); }); diff --git a/src/server/mcp/transport.ts b/src/server/mcp/transport.ts index 75a0036..d0f963b 100644 --- a/src/server/mcp/transport.ts +++ b/src/server/mcp/transport.ts @@ -17,7 +17,18 @@ function createOpenSeoMcpServer() { const server = new McpServer( { 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: