Skip to content
Merged
1 change: 1 addition & 0 deletions landing/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Other:
- Added `/api/list-tools` so clients can preview exactly which tools are available for a given scope/read-only configuration.
- OAuth authorization now preserves and applies client registration context, including read-only defaults, for more predictable permissions UX.
- OAuth metadata now advertises supported grant scope categories for better client discovery.
- Added an anonymous, no-OAuth docs endpoint: `mcp.neon.tech/mcp?category=docs` exposes only the `list_docs_resources` and `get_doc_resource` tools and bypasses OAuth entirely. Triggers strictly when `category=docs` is the only scope and no `projectId` is set; any other combination still requires authentication.

# [0.8.0]

Expand Down
219 changes: 219 additions & 0 deletions landing/app/api/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
getPromptTemplate,
} from '../../../mcp-src/prompts';
import { NEON_HANDLERS } from '../../../mcp-src/tools/index';
import {
getDocResource,
listDocsResources,
} from '../../../mcp-src/tools/handlers/docs';
import { createNeonClient } from '../../../mcp-src/server/api';
import pkg from '../../../package.json';
import { handleToolError } from '../../../mcp-src/server/errors';
Expand All @@ -32,6 +36,7 @@ import { getApiKeys, type ApiKeyRecord } from '../../../mcp-src/oauth/kv-store';
import { setSentryTags } from '../../../mcp-src/sentry/utils';
import type { ServerContext, AppContext } from '../../../mcp-src/types/context';
import {
isDocsOnlyRequest,
resolveGrantFromSearchParams,
resolveGrantFromToken,
DEFAULT_GRANT,
Expand All @@ -42,6 +47,7 @@ import {
getAccessControlWarnings,
injectProjectId,
} from '../../../mcp-src/tools/grant-filter';
import { NEON_TOOLS } from '../../../mcp-src/tools/definitions';
import { assert } from '../../../lib/assert';
import { buildResourceMetadataUrlForResourceRequest } from '../../../lib/oauth/protected-resource-metadata';
import {
Expand Down Expand Up @@ -724,6 +730,202 @@ function createContextualMcpHandler(staticToolContext: StaticToolContext) {
);
}

// The docs-only handler bypasses OAuth entirely. It only registers tools
// scoped to the `docs` category, which currently fetch from neon.com via
// global fetch and never touch the Neon API client. We deliberately avoid
// going through `getAvailableTools` / `grant-filter` here so the
// "always available" search/fetch tools (which require Neon API auth) are
// not surfaced anonymously.
const DOCS_ONLY_TOOLS = NEON_TOOLS.filter((tool) => tool.scope === 'docs');
function getDocsOnlyToolDefinition(
name: 'list_docs_resources' | 'get_doc_resource',
) {
const tool = DOCS_ONLY_TOOLS.find((tool) => tool.name === name);
assert(tool, `${name} tool definition not found`);
return tool;
}

const listDocsResourcesTool = getDocsOnlyToolDefinition('list_docs_resources');
const getDocResourceTool = getDocsOnlyToolDefinition('get_doc_resource');

const ANONYMOUS_DOCS_USER_ID = 'anonymous-docs';

const docsOnlyAppContext: AppContext = {
name: 'mcp-server-neon',
transport: 'stream',
environment: (process.env.NODE_ENV ??
'production') as AppContext['environment'],
version: pkg.version,
};

function createDocsOnlyMcpHandler() {
return createMcpHandler(
(server: McpServer) => {
async function runDocsTool(
toolName: 'list_docs_resources' | 'get_doc_resource',
call: () => Promise<string>,
) {
const traceId = generateTraceId();
return await startSpan(
{
name: 'tool_call',
attributes: {
tool_name: toolName,
trace_id: traceId,
docs_only: true,
},
},
async (span) => {
const properties = {
tool_name: toolName,
readOnly: 'true',
projectScoped: 'false',
clientName: 'anonymous-docs',
traceId,
docsOnly: 'true',
};

logger.info('tool call (docs-only):', properties);

track({
anonymousId: ANONYMOUS_DOCS_USER_ID,
event: 'tool_call',
properties,
context: { app: docsOnlyAppContext },
});
waitUntil(flushAnalytics());

try {
const text = await call();
return {
content: [
{
type: 'text' as const,
text,
},
],
};
} catch (error) {
span.setStatus({ code: 2 });
const errorResult = handleToolError(error, properties, traceId);
logger.warn('tool error response (docs-only):', {
...properties,
isError: true,
contentLength: errorResult.content?.length,
firstContentType: errorResult.content?.[0]?.type,
});
return errorResult;
}
},
);
}

server.registerTool(
listDocsResourcesTool.name,
{
description: listDocsResourcesTool.description,
inputSchema: listDocsResourcesTool.inputSchema,
annotations: listDocsResourcesTool.annotations,
},
async () =>
runDocsTool(listDocsResourcesTool.name, () => listDocsResources()),
);

server.registerTool(
getDocResourceTool.name,
{
description: getDocResourceTool.description,
inputSchema: getDocResourceTool.inputSchema,
annotations: getDocResourceTool.annotations,
},
async (args: { slug: string }) =>
runDocsTool(getDocResourceTool.name, () =>
getDocResource({ slug: args.slug }),
),
);

server.server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools = DOCS_ONLY_TOOLS.map((tool) => {
const normalizedSchema = normalizeObjectSchema(tool.inputSchema);
const inputSchema = normalizedSchema
? toJsonSchemaCompat(normalizedSchema, {
strictUnions: true,
pipeStrategy: 'input',
})
: { type: 'object' as const };

return {
name: tool.name,
title: tool.annotations?.title,
description: tool.description,
inputSchema,
annotations: tool.annotations,
};
});

return { tools };
});
},
{
serverInfo: {
name: 'mcp-server-neon',
version: pkg.version,
},
capabilities: {
tools: {},
},
},
{
redisUrl: process.env.KV_URL || process.env.REDIS_URL,
basePath: '/api',
maxDuration: 800,
verboseLogs: process.env.NODE_ENV !== 'production',
onEvent: (event) => {
switch (event.type) {
case 'SESSION_STARTED':
logger.info('MCP docs-only session started', {
sessionId: event.sessionId,
transport: event.transport,
clientInfo: event.clientInfo,
});
break;
case 'SESSION_ENDED':
logger.info('MCP docs-only session ended', {
sessionId: event.sessionId,
transport: event.transport,
});
break;
case 'REQUEST_COMPLETED':
if (event.status === 'error') {
logger.warn('MCP docs-only request failed', {
sessionId: event.sessionId,
requestId: event.requestId,
method: event.method,
duration: event.duration,
});
}
break;
case 'ERROR':
if (event.severity === 'fatal') {
logger.error('MCP docs-only fatal error', {
sessionId: event.sessionId,
error: event.error,
source: event.source,
context: event.context,
});
captureException(
event.error instanceof Error
? event.error
: new Error(String(event.error)),
);
}
break;
}
},
},
);
}

// Cache TTL for API key verification (5 minutes)
// Balances security (revoked keys stop working soon) with performance (reduce API calls)
const API_KEY_CACHE_TTL_MS = 5 * 60 * 1000;
Expand Down Expand Up @@ -1125,6 +1327,16 @@ function rewriteResourceMetadataHeader(
});
}

// Lazily-initialized docs-only handler. Built on first docs-only request
// so module load doesn't pay the cost when the endpoint is never used.
let docsOnlyHandler: ReturnType<typeof createDocsOnlyMcpHandler> | null = null;
function getDocsOnlyHandler() {
if (!docsOnlyHandler) {
docsOnlyHandler = createDocsOnlyMcpHandler();
}
return docsOnlyHandler;
}

// Normalize legacy paths (/mcp, /sse) to canonical /api/* paths
// for mcp-handler's exact pathname matching.
//
Expand All @@ -1149,6 +1361,13 @@ const handleRequest = (req: Request) => {
duplex: 'half',
});

// Strict docs-only mode: bypass OAuth entirely so docs tools are usable
// without an account. Only triggers when the request is exactly
// ?category=docs (no other categories, no projectId).
if (isDocsOnlyRequest(url.searchParams)) {
return getDocsOnlyHandler()(normalizedReq);
}

const response = authHandler(normalizedReq);
if (response instanceof Promise) {
return response.then((resolved) =>
Expand Down
Loading
Loading