Add more mcp tool metadara + ChatGPT domain verification route (#197)

This commit is contained in:
Ben Senescu 2026-05-14 13:02:08 -04:00 committed by GitHub
parent 0ce787889a
commit 7a0ab0de2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 258 additions and 0 deletions

View File

@ -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,
}

View File

@ -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",
},
});
},
},
},
});

View File

@ -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;

View File

@ -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 };

View File

@ -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(

View File

@ -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(

View File

@ -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) {

View File

@ -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);

View File

@ -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<string, never>,
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<string, never>, extra: ToolExtra) => {
const auth = getAuth(extra);

View File

@ -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<z.ZodObject<typeof inputSchema>>, context) => {

View File

@ -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(

View File

@ -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) {

View File

@ -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<string, never>,
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<string, never>, extra: ToolExtra) => {
const auth = getAuth(extra);