From 7a0ab0de2c81f118c1686e942222bfe5ba2b59d8 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Thu, 14 May 2026 13:02:08 -0400 Subject: [PATCH] Add more mcp tool metadara + ChatGPT domain verification route (#197) --- src/routeTree.gen.ts | 23 +++++++++++ .../[.well-known]/openai-apps-challenge.ts | 19 ++++++++++ src/server/mcp/output-schemas.ts | 18 +++++++++ .../mcp/tools/get-backlinks-overview.ts | 14 +++++++ .../tools/get-domain-keyword-suggestions.ts | 13 +++++++ src/server/mcp/tools/get-domain-overview.ts | 16 ++++++++ src/server/mcp/tools/get-rank-tracker.ts | 17 +++++++++ src/server/mcp/tools/get-serp-results.ts | 38 +++++++++++++++++++ src/server/mcp/tools/list-projects.ts | 20 ++++++++++ src/server/mcp/tools/list-saved-keywords.ts | 15 ++++++++ src/server/mcp/tools/research-keywords.ts | 33 ++++++++++++++++ src/server/mcp/tools/save-keywords.ts | 16 ++++++++ src/server/mcp/tools/whoami.ts | 16 ++++++++ 13 files changed, 258 insertions(+) create mode 100644 src/routes/[.well-known]/openai-apps-challenge.ts create mode 100644 src/server/mcp/output-schemas.ts diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index c3e7a76..b769dde 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as AppSupportRouteImport } from './routes/_app/support' import { Route as AppSettingsRouteImport } from './routes/_app/settings' import { Route as AppBillingRouteImport } from './routes/_app/billing' import { Route as AppAiRouteImport } from './routes/_app/ai' +import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' @@ -120,6 +121,12 @@ const AppAiRoute = AppAiRouteImport.update({ path: '/ai', getParentRoute: () => AppRouteRoute, } as any) +const Char91DotwellKnownChar93OpenaiAppsChallengeRoute = + Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport.update({ + id: '/.well-known/openai-apps-challenge', + path: '/.well-known/openai-apps-challenge', + getParentRoute: () => rootRouteImport, + } as any) const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({ id: '/api/autumn/$', path: '/api/autumn/$', @@ -220,6 +227,7 @@ export interface FileRoutesByFullPath { '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute + '/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute '/ai': typeof AppAiRoute '/billing': typeof AppBillingRoute '/settings': typeof AppSettingsRoute @@ -251,6 +259,7 @@ export interface FileRoutesByTo { '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute + '/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute '/ai': typeof AppAiRoute '/billing': typeof AppBillingRoute '/settings': typeof AppSettingsRoute @@ -283,6 +292,7 @@ export interface FileRoutesById { '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute + '/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute '/_app/ai': typeof AppAiRoute '/_app/billing': typeof AppBillingRoute '/_app/settings': typeof AppSettingsRoute @@ -317,6 +327,7 @@ export interface FileRouteTypes { | '/forgot-password' | '/reset-password' | '/verify-email' + | '/.well-known/openai-apps-challenge' | '/ai' | '/billing' | '/settings' @@ -348,6 +359,7 @@ export interface FileRouteTypes { | '/forgot-password' | '/reset-password' | '/verify-email' + | '/.well-known/openai-apps-challenge' | '/ai' | '/billing' | '/settings' @@ -379,6 +391,7 @@ export interface FileRouteTypes { | '/forgot-password' | '/reset-password' | '/verify-email' + | '/.well-known/openai-apps-challenge' | '/_app/ai' | '/_app/billing' | '/_app/settings' @@ -415,6 +428,7 @@ export interface RootRouteChildren { ForgotPasswordRoute: typeof ForgotPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute VerifyEmailRoute: typeof VerifyEmailRoute + Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute } @@ -533,6 +547,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAiRouteImport parentRoute: typeof AppRouteRoute } + '/.well-known/openai-apps-challenge': { + id: '/.well-known/openai-apps-challenge' + path: '/.well-known/openai-apps-challenge' + fullPath: '/.well-known/openai-apps-challenge' + preLoaderRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport + parentRoute: typeof rootRouteImport + } '/api/autumn/$': { id: '/api/autumn/$' path: '/api/autumn/$' @@ -789,6 +810,8 @@ const rootRouteChildren: RootRouteChildren = { ForgotPasswordRoute: ForgotPasswordRoute, ResetPasswordRoute: ResetPasswordRoute, VerifyEmailRoute: VerifyEmailRoute, + Char91DotwellKnownChar93OpenaiAppsChallengeRoute: + Char91DotwellKnownChar93OpenaiAppsChallengeRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute, } diff --git a/src/routes/[.well-known]/openai-apps-challenge.ts b/src/routes/[.well-known]/openai-apps-challenge.ts new file mode 100644 index 0000000..d1b51b8 --- /dev/null +++ b/src/routes/[.well-known]/openai-apps-challenge.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; + +// Public verification token expected at this well-known URL by OpenAI Apps. +const OPENAI_APPS_CHALLENGE_TOKEN = + "GEqD0QcIISUHCDhQXqm18K9Hm4Fixm8RMbDxz3nUXsw"; + +export const Route = createFileRoute("/.well-known/openai-apps-challenge")({ + server: { + handlers: { + GET: async () => { + return new Response(OPENAI_APPS_CHALLENGE_TOKEN, { + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }); + }, + }, + }, +}); diff --git a/src/server/mcp/output-schemas.ts b/src/server/mcp/output-schemas.ts new file mode 100644 index 0000000..deb0bc5 --- /dev/null +++ b/src/server/mcp/output-schemas.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +const mcpMetaOutputSchema = z + .object({ + url: z.string().optional(), + organizationId: z.string().optional(), + projectId: z.string().optional(), + runId: z.string().optional(), + creditsCharged: z.number().optional(), + creditsRemaining: z.number().optional(), + }) + .passthrough(); + +export const looseObjectOutputSchema = z.record(z.string(), z.unknown()); + +export const optionalMetaOutputSchema = { + meta: mcpMetaOutputSchema.optional(), +} as const; diff --git a/src/server/mcp/tools/get-backlinks-overview.ts b/src/server/mcp/tools/get-backlinks-overview.ts index 44d50e1..35ab54b 100644 --- a/src/server/mcp/tools/get-backlinks-overview.ts +++ b/src/server/mcp/tools/get-backlinks-overview.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService"; import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; +import { + looseObjectOutputSchema, + optionalMetaOutputSchema, +} from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { projectIdSchema } from "@/server/mcp/schemas"; @@ -38,6 +42,16 @@ export const getBacklinksOverviewTool = { 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.", inputSchema, + outputSchema: { + overview: looseObjectOutputSchema, + referringDomains: looseObjectOutputSchema, + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: false, + }, }, handler: withMcpProjectAuth(async (args: Args, context) => { const lookup = { target: args.target, scope: args.scope }; diff --git a/src/server/mcp/tools/get-domain-keyword-suggestions.ts b/src/server/mcp/tools/get-domain-keyword-suggestions.ts index ba73616..b451425 100644 --- a/src/server/mcp/tools/get-domain-keyword-suggestions.ts +++ b/src/server/mcp/tools/get-domain-keyword-suggestions.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { DomainService } from "@/server/features/domain/services/DomainService"; import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; +import { + looseObjectOutputSchema, + optionalMetaOutputSchema, +} from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { DEFAULT_LANGUAGE_CODE, @@ -30,6 +34,15 @@ export const getDomainKeywordSuggestionsTool = { description: "Returns the organic keywords a domain ranks for, including position and available metrics. Use after get_domain_overview when you want the detailed keyword opportunity list for a competitor or reference domain. Charges credits (~100-300 typical). Cached for 12 hours.", inputSchema, + outputSchema: { + keywords: z.array(looseObjectOutputSchema), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: false, + }, }, handler: withMcpProjectAuth(async (args: Args, context) => { const keywords = await DomainService.getSuggestedKeywords( diff --git a/src/server/mcp/tools/get-domain-overview.ts b/src/server/mcp/tools/get-domain-overview.ts index b5e8d3c..1d4fb38 100644 --- a/src/server/mcp/tools/get-domain-overview.ts +++ b/src/server/mcp/tools/get-domain-overview.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { DomainService } from "@/server/features/domain/services/DomainService"; import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; +import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { DEFAULT_LANGUAGE_CODE, @@ -28,6 +29,21 @@ export const getDomainOverviewTool = { description: "Returns a high-level view of a domain's organic footprint: estimated organic traffic, organic keyword count, backlinks, and referring domains. Use this first for domain research; for the detailed ranked-keyword list, call get_domain_keyword_suggestions next. Charges credits (~100-300 typical). Cached for 12 hours per domain.", inputSchema, + outputSchema: z + .object({ + domain: z.string().optional(), + organicTraffic: z.number().nullable().optional(), + organicKeywords: z.number().nullable().optional(), + backlinks: z.number().nullable().optional(), + referringDomains: z.number().nullable().optional(), + ...optionalMetaOutputSchema, + }) + .passthrough(), + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: false, + }, }, handler: withMcpProjectAuth(async (args: Args, context) => { const result = await DomainService.getOverview( diff --git a/src/server/mcp/tools/get-rank-tracker.ts b/src/server/mcp/tools/get-rank-tracker.ts index 22b08bb..d9cd70b 100644 --- a/src/server/mcp/tools/get-rank-tracker.ts +++ b/src/server/mcp/tools/get-rank-tracker.ts @@ -3,6 +3,10 @@ import { RankTrackingRepository } from "@/server/features/rank-tracking/reposito import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults"; import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; +import { + looseObjectOutputSchema, + optionalMetaOutputSchema, +} from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { projectIdSchema } from "@/server/mcp/schemas"; @@ -25,6 +29,19 @@ export const getRankTrackerTool = { 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.", inputSchema, + outputSchema: z + .object({ + configs: z.array(looseObjectOutputSchema).optional(), + config: looseObjectOutputSchema.optional(), + results: looseObjectOutputSchema.optional(), + ...optionalMetaOutputSchema, + }) + .passthrough(), + annotations: { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + }, }, handler: withMcpProjectAuth(async (args: Args, context) => { if (!args.trackerId) { diff --git a/src/server/mcp/tools/get-serp-results.ts b/src/server/mcp/tools/get-serp-results.ts index e74a657..218d57e 100644 --- a/src/server/mcp/tools/get-serp-results.ts +++ b/src/server/mcp/tools/get-serp-results.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { createDataforseoClient } from "@/server/lib/dataforseoClient"; import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; +import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { DEFAULT_LANGUAGE_CODE, @@ -37,6 +38,43 @@ export const getSerpResultsTool = { description: "Fetch live Google organic search results for 1-10 keywords. Use this to inspect who ranks for a query, verify competitors, compare SERPs across keywords, or gather source URLs before content planning. Charges credits per keyword (~30-60 each). Does not save results to OpenSEO. Per-keyword errors don't fail the batch.", inputSchema, + outputSchema: { + results: z.array( + z.union([ + z + .object({ + keyword: z.string(), + ok: z.literal(true), + items: z.array( + z + .object({ + type: z.string().nullable().optional(), + rank: z.number().nullable(), + title: z.string().nullable(), + url: z.string().nullable(), + domain: z.string().nullable(), + description: z.string().nullable(), + }) + .passthrough(), + ), + }) + .passthrough(), + z + .object({ + keyword: z.string(), + ok: z.literal(false), + error: z.string(), + }) + .passthrough(), + ]), + ), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: false, + }, }, handler: withMcpProjectAuth(async (args: Args, context) => { const client = createDataforseoClient(context.billing); diff --git a/src/server/mcp/tools/list-projects.ts b/src/server/mcp/tools/list-projects.ts index 60949d3..e187a0e 100644 --- a/src/server/mcp/tools/list-projects.ts +++ b/src/server/mcp/tools/list-projects.ts @@ -1,7 +1,9 @@ import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { mcpResponse } from "@/server/mcp/formatters"; import { getAuth, getBaseUrl, type ToolExtra } from "@/server/mcp/context"; +import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { buildDashboardUrl } from "@/server/mcp/urls"; +import { z } from "zod"; export const listProjectsTool = { name: "list_projects", @@ -10,6 +12,24 @@ export const listProjectsTool = { 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`.", inputSchema: {} as Record, + outputSchema: { + projects: z.array( + z + .object({ + id: z.string(), + name: z.string(), + domain: z.string().nullable().optional(), + url: z.string(), + }) + .passthrough(), + ), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + }, }, handler: async (_args: Record, extra: ToolExtra) => { const auth = getAuth(extra); diff --git a/src/server/mcp/tools/list-saved-keywords.ts b/src/server/mcp/tools/list-saved-keywords.ts index 7ffd63f..ca4cf39 100644 --- a/src/server/mcp/tools/list-saved-keywords.ts +++ b/src/server/mcp/tools/list-saved-keywords.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; +import { + looseObjectOutputSchema, + optionalMetaOutputSchema, +} from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { projectIdSchema } from "@/server/mcp/schemas"; @@ -31,6 +35,17 @@ export const listSavedKeywordsTool = { 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.", inputSchema, + outputSchema: { + rows: z.array(looseObjectOutputSchema), + totalCount: z.number(), + tags: z.array(looseObjectOutputSchema), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + }, }, handler: withMcpProjectAuth( async (args: z.infer>, context) => { diff --git a/src/server/mcp/tools/research-keywords.ts b/src/server/mcp/tools/research-keywords.ts index 45537ae..f04e66d 100644 --- a/src/server/mcp/tools/research-keywords.ts +++ b/src/server/mcp/tools/research-keywords.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; +import { + looseObjectOutputSchema, + optionalMetaOutputSchema, +} from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { DEFAULT_LANGUAGE_CODE, @@ -41,6 +45,35 @@ export const researchKeywordsTool = { description: "Research keyword data (search volume, difficulty, CPC, related ideas) for 1-5 seed keywords in one call. Charges credits per seed (~50-200 credits each, varies by source). Returns per-seed results — a single bad seed won't fail the batch.", inputSchema, + outputSchema: { + results: z.array( + z.union([ + z + .object({ + seed: z.string(), + ok: z.literal(true), + rowCount: z.number(), + source: z.string(), + usedFallback: z.boolean(), + topRows: z.array(looseObjectOutputSchema), + }) + .passthrough(), + z + .object({ + seed: z.string(), + ok: z.literal(false), + error: z.string(), + }) + .passthrough(), + ]), + ), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: false, + }, }, handler: withMcpProjectAuth(async (args: Args, context) => { const results = await Promise.all( diff --git a/src/server/mcp/tools/save-keywords.ts b/src/server/mcp/tools/save-keywords.ts index 35bc633..7c3553b 100644 --- a/src/server/mcp/tools/save-keywords.ts +++ b/src/server/mcp/tools/save-keywords.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; +import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { DEFAULT_LANGUAGE_CODE, @@ -44,6 +45,21 @@ export const saveKeywordsTool = { 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.", inputSchema, + outputSchema: { + projectId: z.string(), + savedCount: z.number(), + keywords: z.array(z.string()), + tags: z.array(z.string()), + tagMode: z.enum(["append", "replace"]), + locationCode: z.number(), + languageCode: z.string(), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: false, + }, }, handler: withMcpProjectAuth(async (args: Args, context) => { if (args.tagMode === "replace" && (args.tags?.length ?? 0) === 0) { diff --git a/src/server/mcp/tools/whoami.ts b/src/server/mcp/tools/whoami.ts index 4a07b82..0cc9eb1 100644 --- a/src/server/mcp/tools/whoami.ts +++ b/src/server/mcp/tools/whoami.ts @@ -6,6 +6,8 @@ import { import { mcpResponse } from "@/server/mcp/formatters"; import { getAuth, type ToolExtra } from "@/server/mcp/context"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; +import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; +import { z } from "zod"; async function checkBalance(featureId: string, customerId: string) { try { @@ -23,6 +25,20 @@ export const whoamiTool = { 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.", inputSchema: {} as Record, + outputSchema: { + userId: z.string(), + userEmail: z.string(), + organizationId: z.string(), + scopes: z.array(z.string()), + mode: z.enum(["hosted", "self-hosted"]), + creditsRemaining: z.number().nullable(), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, + }, }, handler: async (_args: Record, extra: ToolExtra) => { const auth = getAuth(extra);