From ec3ba67a7d1a0cd6f5c9b03349c8a4f6b1bcf5d7 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Thu, 21 Aug 2025 07:04:59 -0400 Subject: [PATCH 01/10] fix resource alignment --- netlify/functions/oauth-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netlify/functions/oauth-server.ts b/netlify/functions/oauth-server.ts index 57c7026..8a051d6 100644 --- a/netlify/functions/oauth-server.ts +++ b/netlify/functions/oauth-server.ts @@ -151,7 +151,7 @@ const oAuthHandler: Handler = async (req, context) => { let oidcConfig = typeof response.body === 'string' ? JSON.parse(response.body) : response.body; if(getProtectedResource){ - oidcConfig.resource = getOAuthIssuer(); + oidcConfig.resource = new URL('/mcp', getOAuthIssuer()).toString(); } oidcConfig = urlsToHTTP(oidcConfig, getOAuthIssuer()); From e2901621d2246671c1f30e81ba890362b017507a Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Thu, 11 Sep 2025 16:07:16 -0400 Subject: [PATCH 02/10] ensure mcp server responses are parsed correctly --- netlify/functions/mcp-server/utils.ts | 7 ++++++- netlify/functions/mcp.ts | 2 +- netlify/functions/oauth-server.ts | 4 ++-- package.json | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/netlify/functions/mcp-server/utils.ts b/netlify/functions/mcp-server/utils.ts index 718ffca..17f985a 100644 --- a/netlify/functions/mcp-server/utils.ts +++ b/netlify/functions/mcp-server/utils.ts @@ -8,11 +8,16 @@ export function getOAuthIssuer(): string { return process.env.OAUTH_ISSUER || 'http://localhost:8888/'; } -export function addCORSHeadersToHandlerResp(response: HandlerResponse): HandlerResponse { +export function addCommonHeadersToHandlerResp(response: HandlerResponse): HandlerResponse { const respHeaders = headersToHeadersObject(response.headers as Record | Headers || {}); respHeaders.set('Access-Control-Allow-Origin', '*'); respHeaders.set('Access-Control-Allow-Methods', '*'); respHeaders.set('Access-Control-Allow-Headers', '*'); + + if(response.statusCode === 200) { + respHeaders.set('Content-type', 'application/json'); + } + response.headers = Object.fromEntries(respHeaders.entries()); return response; } diff --git a/netlify/functions/mcp.ts b/netlify/functions/mcp.ts index 2f0f3c4..0de9603 100644 --- a/netlify/functions/mcp.ts +++ b/netlify/functions/mcp.ts @@ -1,6 +1,6 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { toFetchResponse, toReqRes } from "fetch-to-node"; -import { addCORSHeadersToFetchResp, addCORSHeadersToHandlerResp, headersToHeadersObject, returnNeedsAuthResponse } from "./mcp-server/utils.ts"; +import { addCORSHeadersToFetchResp, addCommonHeadersToHandlerResp, headersToHeadersObject, returnNeedsAuthResponse } from "./mcp-server/utils.ts"; import { getContextConsumerConfig, getNetlifyCodingContext } from "../../src/context/coding-context.ts"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getPackageVersion } from "../../src/utils/version.ts"; diff --git a/netlify/functions/oauth-server.ts b/netlify/functions/oauth-server.ts index 8a051d6..d911973 100644 --- a/netlify/functions/oauth-server.ts +++ b/netlify/functions/oauth-server.ts @@ -3,7 +3,7 @@ import type { Handler, HandlerResponse, HandlerEvent, HandlerContext } from "@ne import { Provider } from "oidc-provider"; import type { Configuration } from "oidc-provider"; import { handleAuthStart, handleClientSideAuthExchange, handleCodeExchange, handleServerSideAuthRedirect } from "./mcp-server/auth-flow.ts"; -import { getOAuthIssuer, addCORSHeadersToHandlerResp, headersToHeadersObject, getParsedUrl, urlsToHTTP } from "./mcp-server/utils.ts"; +import { getOAuthIssuer, addCommonHeadersToHandlerResp, headersToHeadersObject, getParsedUrl, urlsToHTTP } from "./mcp-server/utils.ts"; const authorizationEndpointPath = '/oauth-server/auth'; const tokenEndpointPath = '/oauth-server/token'; @@ -226,7 +226,7 @@ const oAuthHandler: Handler = async (req, context) => { export const handler: Handler = async (req, context) => { const resp = await oAuthHandler(req, context); - return resp ? addCORSHeadersToHandlerResp(resp) : { + return resp ? addCommonHeadersToHandlerResp(resp) : { statusCode: 500, body: JSON.stringify({ error: 'Internal Server Error' }), headers: { 'Content-Type': 'application/json' } diff --git a/package.json b/package.json index c79dd5f..1ebade0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netlify/mcp", - "version": "1.10.0", + "version": "1.11.0", "description": "", "type": "module", "main": "./dist/netlify-mcp.js", From 6aaeb25bcebb1de3e204109410e57ef6b70571cb Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Thu, 11 Sep 2025 16:24:05 -0400 Subject: [PATCH 03/10] improve json check --- netlify/functions/mcp-server/utils.ts | 6 ++++-- package.json | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/netlify/functions/mcp-server/utils.ts b/netlify/functions/mcp-server/utils.ts index 17f985a..df0fc88 100644 --- a/netlify/functions/mcp-server/utils.ts +++ b/netlify/functions/mcp-server/utils.ts @@ -14,8 +14,10 @@ export function addCommonHeadersToHandlerResp(response: HandlerResponse): Handle respHeaders.set('Access-Control-Allow-Methods', '*'); respHeaders.set('Access-Control-Allow-Headers', '*'); - if(response.statusCode === 200) { - respHeaders.set('Content-type', 'application/json'); + if(response.statusCode === 200 && response.body) { + if(['{', '['].includes(response.body.trim().charAt(0))) { + respHeaders.set('Content-type', 'application/json'); + } } response.headers = Object.fromEntries(respHeaders.entries()); diff --git a/package.json b/package.json index 1ebade0..6249a4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netlify/mcp", - "version": "1.11.0", + "version": "1.12.0", "description": "", "type": "module", "main": "./dist/netlify-mcp.js", From 784d89130e9fc234edf7f550cd76686d5ac0ce40 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Wed, 8 Oct 2025 16:56:30 -0400 Subject: [PATCH 04/10] update the MCP to have toolannotations --- netlify-mcp.ts | 14 +- package-lock.json | 14 +- package.json | 2 +- src/tools/deploy-tools/deploy-site.ts | 6 + src/tools/deploy-tools/get-deploy-for-site.ts | 3 + src/tools/deploy-tools/get-deploy.ts | 3 + .../change-extension-installation.ts | 3 + src/tools/extension-tools/get-extensions.ts | 3 + .../get-full-extension-details.ts | 3 + .../extension-tools/initialize-database.ts | 3 + src/tools/index.ts | 150 +++++++++++------- src/tools/project-tools/create-new-project.ts | 3 + .../project-tools/get-forms-for-project.ts | 3 + src/tools/project-tools/get-project.ts | 3 + src/tools/project-tools/get-projects.ts | 3 + .../project-tools/manage-form-submissions.ts | 3 + .../project-tools/manage-project-env-vars.ts | 3 + .../project-tools/update-project-forms.ts | 3 + .../project-tools/update-project-name.ts | 3 + .../update-visitor-access-controls.ts | 3 + src/tools/team-tools/get-team.ts | 3 + src/tools/team-tools/get-teams.ts | 3 + src/tools/tool-utils.ts | 13 ++ src/tools/types.ts | 2 + src/tools/user-tools/get-user.ts | 3 + 25 files changed, 186 insertions(+), 69 deletions(-) diff --git a/netlify-mcp.ts b/netlify-mcp.ts index 931cd15..bf59757 100644 --- a/netlify-mcp.ts +++ b/netlify-mcp.ts @@ -76,11 +76,17 @@ if(process.argv.includes('--proxy-path') && proxyPath) { const contextConsumer = await getContextConsumerConfig(); const availableContextTypes = Object.keys(contextConsumer?.contextScopes || {}); const creationTypeEnum = z.enum(availableContextTypes as [string, ...string[]]); - - server.tool( + server.registerTool( "netlify-coding-rules", - "ALWAYS call when writing serverless or Netlify code. required step before creating or editing any type of functions, Netlify sdk/library usage, etc.", - { creationType: creationTypeEnum }, + { + description: "ALWAYS call when writing serverless or Netlify code. required step before creating or editing any type of functions, Netlify sdk/library usage, etc.", + inputSchema:{ + creationType: creationTypeEnum + }, + annotations: { + readOnlyHint: true + } + }, async ({creationType}: {creationType: z.infer}) => { checkCompatibility(); diff --git a/package-lock.json b/package-lock.json index 97d1d2d..95973db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@netlify/mcp", - "version": "1.9.2", + "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@netlify/mcp", - "version": "1.9.2", + "version": "1.12.0", "license": "ISC", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.19.1", "@netlify/edge-functions": "^2.15.0", "@netlify/functions": "^4.1.4", "archiver": "^7.0.1", @@ -645,16 +645,16 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz", - "integrity": "sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==", - "license": "MIT", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.19.1.tgz", + "integrity": "sha512-3Y2h3MZKjec1eAqSTBclATlX+AbC6n1LgfVzRMJLt3v6w0RCYgwLrjbxPDbhsYHt6Wdqc/aCceNJYgj448ELQQ==", "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", diff --git a/package.json b/package.json index 6249a4e..def8ff0 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "author": "", "license": "ISC", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.19.1", "@netlify/edge-functions": "^2.15.0", "@netlify/functions": "^4.1.4", "archiver": "^7.0.1", diff --git a/src/tools/deploy-tools/deploy-site.ts b/src/tools/deploy-tools/deploy-site.ts index cb47c90..2b53594 100644 --- a/src/tools/deploy-tools/deploy-site.ts +++ b/src/tools/deploy-tools/deploy-site.ts @@ -21,6 +21,9 @@ export const deploySiteRemotelyDomainTool: DomainTool { const proxyToken = await createJWE({ @@ -67,6 +70,9 @@ export const deploySiteDomainTool: DomainTool = { domain: 'deploy', operation: 'deploy-site', inputSchema: deploySiteParamsSchema, + toolAnnotations: { + readOnlyHint: false, + }, omitFromRemoteMCP: true, cb: async (params, {request}) => { diff --git a/src/tools/deploy-tools/get-deploy-for-site.ts b/src/tools/deploy-tools/get-deploy-for-site.ts index 5f5e78f..c8719cf 100644 --- a/src/tools/deploy-tools/get-deploy-for-site.ts +++ b/src/tools/deploy-tools/get-deploy-for-site.ts @@ -12,6 +12,9 @@ export const getDeployBySiteIdDomainTool: DomainTool { const { siteId, deployId } = params; return JSON.stringify(await getAPIJSONResult(`/api/v1/sites/${siteId}/deploys/${deployId}`, {}, {}, request)); diff --git a/src/tools/deploy-tools/get-deploy.ts b/src/tools/deploy-tools/get-deploy.ts index 3ad0c3a..aebadd5 100644 --- a/src/tools/deploy-tools/get-deploy.ts +++ b/src/tools/deploy-tools/get-deploy.ts @@ -10,6 +10,9 @@ export const getDeployByIdDomainTool: DomainTool { const { deployId } = params; return JSON.stringify(await getAPIJSONResult(`/api/v1/deploys/${deployId}`, {}, {}, request)); diff --git a/src/tools/extension-tools/change-extension-installation.ts b/src/tools/extension-tools/change-extension-installation.ts index fe001ca..4fb0b64 100644 --- a/src/tools/extension-tools/change-extension-installation.ts +++ b/src/tools/extension-tools/change-extension-installation.ts @@ -15,6 +15,9 @@ export const changeExtensionInstallationDomainTool: DomainTool { try { diff --git a/src/tools/extension-tools/get-extensions.ts b/src/tools/extension-tools/get-extensions.ts index dc44190..783c258 100644 --- a/src/tools/extension-tools/get-extensions.ts +++ b/src/tools/extension-tools/get-extensions.ts @@ -8,6 +8,9 @@ export const getExtensionsDomainTool: DomainTool { return JSON.stringify({ context: 'This list of extensions is available to any Netlify team. This list DOES NOT inform if the extension is installed or configured for a particular team or account.', diff --git a/src/tools/extension-tools/get-full-extension-details.ts b/src/tools/extension-tools/get-full-extension-details.ts index 2cab389..71d457f 100644 --- a/src/tools/extension-tools/get-full-extension-details.ts +++ b/src/tools/extension-tools/get-full-extension-details.ts @@ -11,6 +11,9 @@ export const getFullExtensionDetailsDomainTool: DomainTool { return JSON.stringify(await getExtension({ extensionSlug, accountId: teamId, request })); } diff --git a/src/tools/extension-tools/initialize-database.ts b/src/tools/extension-tools/initialize-database.ts index 34a68e8..7d04e02 100644 --- a/src/tools/extension-tools/initialize-database.ts +++ b/src/tools/extension-tools/initialize-database.ts @@ -8,6 +8,9 @@ export const initializeDatabaseDomainTool: DomainTool { return 'Ensure the @netlify/neon npm package is installed. After installation, restart the development server or run new build.'; } diff --git a/src/tools/index.ts b/src/tools/index.ts index 9b1b744..ba17132 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -31,24 +31,26 @@ import { extensionDomainTools } from './extension-tools/index.js'; import { checkCompatibility } from '../utils/compatibility.js'; import { getNetlifyAccessToken, NetlifyUnauthError } from '../utils/api-networking.js'; import { appendToLog } from '../utils/logging.js'; +import { categorizeToolsByReadWrite } from './tool-utils.js'; import { z } from 'zod'; import type { DomainTool } from './types.js'; const listOfDomainTools = [userDomainTools, deployDomainTools, teamDomainTools, projectDomainTools, extensionDomainTools]; +const toSelectorSchema = (domainTool: DomainTool) => { + return z.object({ + // domain: z.literal(domainTool.domain), + operation: z.literal(domainTool.operation), + params: domainTool.inputSchema, + + llmModelName: z.string().optional(), + aiAgentName: z.string().optional() + }); +} + export const bindTools = async (server: McpServer, remoteMCPRequest?: Request) => { const isRemoteMCP = !!remoteMCPRequest; - const toSelectorSchema = (domainTool: DomainTool>) => { - return z.object({ - // domain: z.literal(domainTool.domain), - operation: z.literal(domainTool.operation), - params: domainTool.inputSchema, - - llmModelName: z.string().optional(), - aiAgentName: z.string().optional() - }); - } listOfDomainTools.forEach(domainTools => { @@ -62,66 +64,100 @@ export const bindTools = async (server: McpServer, remoteMCPRequest?: Request) = } return true; }); - const toolOperations = filteredDomainTools.map(tool => tool.operation); - - // join the input schemas of all domain tools into a raw array with - // to give the llm the ability to select. - const paramsSchema = { - // @ts-ignore - selectSchema: filteredDomainTools.length > 1 ? z.union(filteredDomainTools.map(tool => toSelectorSchema(tool))) : toSelectorSchema(filteredDomainTools[0]) - }; - - const toolName = `netlify-${domain}-services`; - const toolDescription = `Select and run one of the following Netlify operations ${toolOperations.join(', ')}`; - server.tool(toolName, toolDescription, paramsSchema, async (...args) => { - checkCompatibility(); - - try { - - await getNetlifyAccessToken(remoteMCPRequest); - } catch (error: NetlifyUnauthError | any) { - - // rethrow error to the top level handler to catch - // so we can update the fn request to return a proper - // server response instead of a tool response - if (error instanceof NetlifyUnauthError && remoteMCPRequest) { - throw new NetlifyUnauthError(); - } - - return { - content: [{ type: "text", text: error?.message || 'Failed to get Netlify token' }], - isError: true - }; - } - appendToLog(`${toolName} operation: ${JSON.stringify(args)}`); + // Categorize tools into read-only and write operations + const { readOnlyTools, writeTools } = categorizeToolsByReadWrite(filteredDomainTools); + + // Register read-only tools if any exist + if (readOnlyTools.length > 0) { + registerDomainTools(server, readOnlyTools, domain, 'read', remoteMCPRequest); + } - const selectedSchema = args[0]?.selectSchema; + // Register write tools if any exist + if (writeTools.length > 0) { + registerDomainTools(server, writeTools, domain, 'write', remoteMCPRequest); + } + }); +}; - if (!selectedSchema) { - return { - content: [{ type: "text", text: 'Failed to select a valid operation. Retry the MCP operation but select the operation and provide the right inputs.' }] - } +const registerDomainTools = ( + server: McpServer, + tools: DomainTool[], + domain: string, + operationType: 'read' | 'write', + remoteMCPRequest?: Request +) => { + const toolOperations = tools.map(tool => tool.operation); + + // join the input schemas of all domain tools into a raw array with + // to give the llm the ability to select. + const paramsSchema = { + // @ts-ignore + selectSchema: tools.length > 1 ? z.union(tools.map(tool => toSelectorSchema(tool))) : toSelectorSchema(tools[0]) + }; + + const readOnlyIndicator = operationType === 'read' ? ' (read-only)' : ''; + const friendlyOperationType = operationType === 'read' ? 'reader' : 'updater'; + const toolName = `netlify-${domain}-services-${friendlyOperationType}`; + const toolDescription = `Select and run one of the following Netlify ${operationType} operations${readOnlyIndicator} ${toolOperations.join(', ')}`; + + + server.registerTool(toolName, { + description: toolDescription, + inputSchema: paramsSchema, + annotations: { + readOnlyHint: operationType === 'read' + } + }, async (...args) => { + + // server.tool(toolName, toolDescription, paramsSchema, async (...args) => { + checkCompatibility(); + + try { + + await getNetlifyAccessToken(remoteMCPRequest); + } catch (error: NetlifyUnauthError | any) { + + // rethrow error to the top level handler to catch + // so we can update the fn request to return a proper + // server response instead of a tool response + if (error instanceof NetlifyUnauthError && remoteMCPRequest) { + throw new NetlifyUnauthError(); } - const operation = selectedSchema.operation; + return { + content: [{ type: "text", text: error?.message || 'Failed to get Netlify token' }], + isError: true + }; + } + + appendToLog(`${toolName} operation: ${JSON.stringify(args)}`); - const subtool = filteredDomainTools.find(subtool => subtool.operation === operation); + const selectedSchema = args[0]?.selectSchema as any; - if (!subtool) { - return { - content: [{ type: "text", text: 'Agent called the wrong MCP tool for this operation.' }] - } + if (!selectedSchema) { + return { + content: [{ type: "text", text: 'Failed to select a valid operation. Retry the MCP operation but select the operation and provide the right inputs.' }] } + } - const result = await subtool.cb(selectedSchema.params, {request: remoteMCPRequest, isRemoteMCP}); + const operation = selectedSchema.operation; - appendToLog(`${domain} operation result: ${JSON.stringify(result)}`); + const subtool = tools.find(subtool => subtool.operation === operation); + if (!subtool) { return { - content: [{ type: "text", text: JSON.stringify(result) }] + content: [{ type: "text", text: 'Agent called the wrong MCP tool for this operation.' }] } - }); + } + + const result = await subtool.cb(selectedSchema.params, {request: remoteMCPRequest, isRemoteMCP: !!remoteMCPRequest}); + + appendToLog(`${domain} operation result: ${JSON.stringify(result)}`); + + return { + content: [{ type: "text", text: JSON.stringify(result) }] + } }); }; diff --git a/src/tools/project-tools/create-new-project.ts b/src/tools/project-tools/create-new-project.ts index 8f043e3..b85c6c5 100644 --- a/src/tools/project-tools/create-new-project.ts +++ b/src/tools/project-tools/create-new-project.ts @@ -15,6 +15,9 @@ export const createNewProjectDomainTool: DomainTool { const site = await getAPIJSONResult(`/api/v1/sites${teamSlug ? `?account_slug=${teamSlug}` : ''}`, { diff --git a/src/tools/project-tools/get-forms-for-project.ts b/src/tools/project-tools/get-forms-for-project.ts index 272a54f..56dd5bd 100644 --- a/src/tools/project-tools/get-forms-for-project.ts +++ b/src/tools/project-tools/get-forms-for-project.ts @@ -12,6 +12,9 @@ export const getFormsForProjectDomainTool: DomainTool { const forms = await getAPIJSONResult(`/api/v1/sites/${siteId}/forms`, {}, {}, request); diff --git a/src/tools/project-tools/get-project.ts b/src/tools/project-tools/get-project.ts index a13882c..06a0c61 100644 --- a/src/tools/project-tools/get-project.ts +++ b/src/tools/project-tools/get-project.ts @@ -12,6 +12,9 @@ export const getProjectDomainTool: DomainTool = { domain: 'project', operation: 'get-project', inputSchema: getProjectParamsSchema, + toolAnnotations: { + readOnlyHint: true, + }, cb: async ({ siteId }, {request}) => { return JSON.stringify(getEnrichedSiteModelForLLM(await getAPIJSONResult(`/api/v1/sites/${siteId}`, {}, {}, request))); } diff --git a/src/tools/project-tools/get-projects.ts b/src/tools/project-tools/get-projects.ts index 13c8e7b..74fdfb9 100644 --- a/src/tools/project-tools/get-projects.ts +++ b/src/tools/project-tools/get-projects.ts @@ -14,6 +14,9 @@ export const getProjectsDomainTool: DomainTool = domain: 'project', operation: 'get-projects', inputSchema: getProjectParamsSchema, + toolAnnotations: { + readOnlyHint: true, + }, cb: async ({ teamSlug, projectNameSearchValue }, {request}) => { let apiResults; diff --git a/src/tools/project-tools/manage-form-submissions.ts b/src/tools/project-tools/manage-form-submissions.ts index dada49e..0490289 100644 --- a/src/tools/project-tools/manage-form-submissions.ts +++ b/src/tools/project-tools/manage-form-submissions.ts @@ -16,6 +16,9 @@ export const manageFormSubmissionsDomainTool: DomainTool { if(action === 'delete-submission'){ diff --git a/src/tools/project-tools/manage-project-env-vars.ts b/src/tools/project-tools/manage-project-env-vars.ts index 39866c4..f97d9f5 100644 --- a/src/tools/project-tools/manage-project-env-vars.ts +++ b/src/tools/project-tools/manage-project-env-vars.ts @@ -22,6 +22,9 @@ export const manageEnvVarsDomainTool: DomainTool { const site = await getAPIJSONResult(`/api/v1/sites/${siteId}`, {}, {}, request); diff --git a/src/tools/project-tools/update-project-forms.ts b/src/tools/project-tools/update-project-forms.ts index 5cc2f86..c4f508e 100644 --- a/src/tools/project-tools/update-project-forms.ts +++ b/src/tools/project-tools/update-project-forms.ts @@ -13,6 +13,9 @@ export const updateFormsDomainTool: DomainTool = domain: 'project', operation: 'update-forms', inputSchema: getProjectParamsSchema, + toolAnnotations: { + readOnlyHint: false, + }, cb: async ({ siteId, forms }, {request}) => { if(forms === undefined) { diff --git a/src/tools/project-tools/update-project-name.ts b/src/tools/project-tools/update-project-name.ts index fa25a3c..db85f5d 100644 --- a/src/tools/project-tools/update-project-name.ts +++ b/src/tools/project-tools/update-project-name.ts @@ -13,6 +13,9 @@ export const updateProjectNameDomainTool: DomainTool { if(name === undefined || name === '') { diff --git a/src/tools/project-tools/update-visitor-access-controls.ts b/src/tools/project-tools/update-visitor-access-controls.ts index 121ad73..a913752 100644 --- a/src/tools/project-tools/update-visitor-access-controls.ts +++ b/src/tools/project-tools/update-visitor-access-controls.ts @@ -16,6 +16,9 @@ export const updateVisitorAccessControlsDomainTool: DomainTool { if(requireSSOTeamLogin === undefined && requirePassword === undefined) { diff --git a/src/tools/team-tools/get-team.ts b/src/tools/team-tools/get-team.ts index 77c568d..e57282e 100644 --- a/src/tools/team-tools/get-team.ts +++ b/src/tools/team-tools/get-team.ts @@ -12,6 +12,9 @@ export const getTeamDomainTool: DomainTool = { domain: 'team', operation: 'get-team', inputSchema: getTeamParamsSchema, + toolAnnotations: { + readOnlyHint: true, + }, cb: async ({ teamId }, {request}) => { return JSON.stringify(getEnrichedTeamModelForLLM(await getAPIJSONResult(`/api/v1/accounts/${teamId}`, {}, {}, request))); } diff --git a/src/tools/team-tools/get-teams.ts b/src/tools/team-tools/get-teams.ts index e7c19d0..7b5f8ad 100644 --- a/src/tools/team-tools/get-teams.ts +++ b/src/tools/team-tools/get-teams.ts @@ -10,6 +10,9 @@ export const getTeamsDomainTool: DomainTool = { domain: 'team', operation: 'get-teams', inputSchema: getTeamsParamsSchema, + toolAnnotations: { + readOnlyHint: true, + }, cb: async (_, {request}) => { return JSON.stringify(getEnrichedTeamModelForLLM(await getAPIJSONResult('/api/v1/accounts', {}, {}, request))); } diff --git a/src/tools/tool-utils.ts b/src/tools/tool-utils.ts index 9f71731..e83dea6 100644 --- a/src/tools/tool-utils.ts +++ b/src/tools/tool-utils.ts @@ -1,6 +1,19 @@ +import type { DomainTool } from './types.js'; +import { z } from 'zod'; + export const createToolResponseWithFollowup = (respPayload: any, followup: string)=>{ return { followupForAgentsOnly: followup, rawToolResponse: respPayload }; } + +export const categorizeToolsByReadWrite = (domainTools: DomainTool[]) => { + const readOnlyTools = domainTools.filter(tool => tool.toolAnnotations.readOnlyHint === true); + const writeTools = domainTools.filter(tool => tool.toolAnnotations.readOnlyHint === false || tool.toolAnnotations.readOnlyHint === undefined); + + return { + readOnlyTools, + writeTools + }; +}; diff --git a/src/tools/types.ts b/src/tools/types.ts index bcc8c88..9d17ce8 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -1,9 +1,11 @@ +import { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; export interface DomainTool { domain: ToolDomain; operation: string; inputSchema: T; + toolAnnotations: ToolAnnotations; omitFromRemoteMCP?: boolean; omitFromLocalMCP?: boolean; cb: (input: z.infer, mcpContext: MCPEnvContext) => Promise; diff --git a/src/tools/user-tools/get-user.ts b/src/tools/user-tools/get-user.ts index bd200ed..f316c9d 100644 --- a/src/tools/user-tools/get-user.ts +++ b/src/tools/user-tools/get-user.ts @@ -9,6 +9,9 @@ export const getUserDomainTool: DomainTool = { domain: 'user', operation: 'get-user', inputSchema: getUserParamsSchema, + toolAnnotations: { + readOnlyHint: true, + }, cb: async (_, {request}) => { return JSON.stringify(await getAPIJSONResult('/api/v1/user', {}, {}, request)); } From 5d5ff3b27f3f091220382b5a41aabc3e3c1671e3 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Wed, 8 Oct 2025 16:57:43 -0400 Subject: [PATCH 05/10] breakup tools and support tool annotations --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index def8ff0..8b2c281 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netlify/mcp", - "version": "1.12.0", + "version": "1.13.0", "description": "", "type": "module", "main": "./dist/netlify-mcp.js", From 2110fb171687e87904d03ebb345431a363c18997 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Tue, 14 Oct 2025 12:05:02 -0400 Subject: [PATCH 06/10] support iss parameter --- netlify/functions/mcp-server/auth-flow.ts | 47 ++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/netlify/functions/mcp-server/auth-flow.ts b/netlify/functions/mcp-server/auth-flow.ts index b82ab29..a6f0a93 100644 --- a/netlify/functions/mcp-server/auth-flow.ts +++ b/netlify/functions/mcp-server/auth-flow.ts @@ -1,6 +1,6 @@ import { HandlerResponse } from "@netlify/functions"; import { createHash } from "crypto"; -import { createJWE, decryptJWE } from "./utils.ts"; +import { createJWE, decryptJWE, getOAuthIssuer } from "./utils.ts"; interface CODE_JWE_PAYLOAD { state: Record; @@ -19,6 +19,27 @@ export async function handleAuthStart(req: Request): Promise{ const missingParams = requiredParams.filter(param => !params.get(param)); if (missingParams.length > 0) { + // RFC 9207: Return error via redirect with iss parameter if redirect_uri is available + const redirectUri = params.get('redirect_uri'); + if (redirectUri) { + try { + const errorRedirectUrl = new URL(redirectUri); + errorRedirectUrl.searchParams.set('error', 'invalid_request'); + errorRedirectUrl.searchParams.set('error_description', `Missing required parameters: ${missingParams.join(', ')}`); + errorRedirectUrl.searchParams.set('iss', getOAuthIssuer()); + const state = params.get('state'); + if (state) { + errorRedirectUrl.searchParams.set('state', state); + } + return { + statusCode: 302, + headers: { 'Location': errorRedirectUrl.toString() }, + body: '' + }; + } catch (e) { + // Invalid redirect_uri, fall through to JSON error + } + } return { statusCode: 400, body: JSON.stringify({ @@ -125,6 +146,9 @@ export async function handleServerSideAuthRedirect(req: Request): Promise Date: Tue, 14 Oct 2025 12:31:28 -0400 Subject: [PATCH 07/10] improve req/res body handling --- netlify/functions/mcp.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/netlify/functions/mcp.ts b/netlify/functions/mcp.ts index 0de9603..4802306 100644 --- a/netlify/functions/mcp.ts +++ b/netlify/functions/mcp.ts @@ -65,17 +65,29 @@ export default async (req: Request) => { async function handleMCPPost(req: Request) { - // Convert the Request object into a Node.js Request object - const { req: nodeReq, res: nodeRes } = toReqRes(req); - + // Read body once and reuse it + let body: any; try { + body = await req.json(); console.log('Handling MCP POST request', { - body: JSON.stringify(await (await req.clone()).json(), null, 2), + body: JSON.stringify(body, null, 2), }); } catch (error) { console.error('Error reading request body:', error); + return new Response('Invalid JSON body', {status: 400}); } + // Create a new Request with the body as a string to avoid re-reading issues + // toReqRes will try to read the body, so we need to provide a fresh request + const reqWithBody = new Request(req.url, { + method: req.method, + headers: req.headers, + body: JSON.stringify(body), + }); + + // Convert the Request object into a Node.js Request object + const { req: nodeReq, res: nodeRes } = toReqRes(reqWithBody); + // Right now, the MCP spec is inconcistent on _when_ // 401s can be returned. So, we will always do the auth // check, including for init. @@ -127,7 +139,6 @@ async function handleMCPPost(req: Request) { await server.connect(transport); - const body = await req.json(); await transport.handleRequest(nodeReq, nodeRes, body); nodeRes.on("close", () => { @@ -137,7 +148,7 @@ async function handleMCPPost(req: Request) { const response = await toFetchResponse(nodeRes); try { - const returnData = await (await response.clone()).text(); + const returnData = await response.clone().text(); if(returnData.includes(UNAUTHED_ERROR_PREFIX)){ console.error("Unauthorized error detected in response:", returnData); From 8c1601123375a6863dced4217dbbfdd5c71be721 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Tue, 21 Oct 2025 10:26:21 -0400 Subject: [PATCH 08/10] feat: create a static azure ai foundry oauth client --- netlify/functions/oauth-server.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/netlify/functions/oauth-server.ts b/netlify/functions/oauth-server.ts index d911973..4a9d46e 100644 --- a/netlify/functions/oauth-server.ts +++ b/netlify/functions/oauth-server.ts @@ -1,7 +1,7 @@ import serverless from "serverless-http"; import type { Handler, HandlerResponse, HandlerEvent, HandlerContext } from "@netlify/functions"; import { Provider } from "oidc-provider"; -import type { Configuration } from "oidc-provider"; +import type { Configuration, ClientMetadata } from "oidc-provider"; import { handleAuthStart, handleClientSideAuthExchange, handleCodeExchange, handleServerSideAuthRedirect } from "./mcp-server/auth-flow.ts"; import { getOAuthIssuer, addCommonHeadersToHandlerResp, headersToHeadersObject, getParsedUrl, urlsToHTTP } from "./mcp-server/utils.ts"; @@ -11,6 +11,28 @@ const clientRedirectPath = '/oauth-server/client-redirect'; const serverRedirectPath = '/oauth-server/server-redirect'; const registrationEndpointPath = '/oauth-server/reg'; +// Static OAuth clients - add your pre-configured clients here +const staticClients: ClientMetadata[] = [ + // Azure AI Foundry wants to do authentication on customer behalf but does + // not support dynamic client registration yet. They asked to have us provision a dedicated + // client for them. Here are the details they provided. + // Point of contacts for Azure AI Foundry + // zhuoqunli@microsoft.com + // AzureToolsCatalog@microsoft.com + { + client_id: 'azure-ai-foundry', + client_secret: Netlify.env.get('CLIENT_SECRET_AZURE_AI_FOUNDRY'), // Use secure secrets in production + redirect_uris: [ + 'https://global.consent.azure-apim.net/redirect/foundrynetlifymcp', + 'https://global-test.consent.azure-apim.net/redirect/foundrynetlifymcp' + ], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + }, + // Add more static clients as needed +]; + // MCP-compliant OAuth2/OIDC server configuration // Minimal MCP-compliant OAuth2/OIDC server configuration const configuration: Configuration = { @@ -53,6 +75,9 @@ const configuration: Configuration = { userinfo: { enabled: false }, // TODO: future Enable userinfo endpoint }, + // static clients via findAccount-style lookup + clients: staticClients, + // we don't use all of these but we prefix them to ensure our fn handles them routes: { authorization: authorizationEndpointPath, From aa3f1f429fee07eaaaa3019c30c9bcf9a4a4462d Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Tue, 21 Oct 2025 13:24:14 -0400 Subject: [PATCH 09/10] feat: handle dcr better --- netlify/functions/oauth-server.ts | 53 +++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/netlify/functions/oauth-server.ts b/netlify/functions/oauth-server.ts index 4a9d46e..8fcc3bc 100644 --- a/netlify/functions/oauth-server.ts +++ b/netlify/functions/oauth-server.ts @@ -33,9 +33,59 @@ const staticClients: ClientMetadata[] = [ // Add more static clients as needed ]; +// In-memory storage for dynamically registered clients +const dynamicClients = new Map(); + +// Adapter to support both static and dynamic clients +class ClientAdapter { + constructor(private name: string) {} + + async upsert(id: string, payload: any, expiresIn?: number) { + if (this.name === 'Client') { + const staticClient = staticClients.find(c => c.client_id === id); + if (!staticClient) { + console.log('Registering dynamic client:', id, payload, expiresIn); + dynamicClients.set(id, payload); + } + } + } + + async find(id: string) { + if (this.name === 'Client') { + // Check static clients first + const staticClient = staticClients.find(c => c.client_id === id); + if (staticClient) { + return staticClient; + } + // Then check dynamic clients + return dynamicClients.get(id); + } + return undefined; + } + + async findByUserCode(userCode: string) { + return undefined; + } + + async findByUid(uid: string) { + return undefined; + } + + async destroy(id: string) { + if (this.name === 'Client') { + dynamicClients.delete(id); + } + } + + async revokeByGrantId(grantId: string) {} + + async consume(id: string) {} +} + // MCP-compliant OAuth2/OIDC server configuration // Minimal MCP-compliant OAuth2/OIDC server configuration const configuration: Configuration = { + adapter: ClientAdapter, // Only allow Authorization Code flow responseTypes: ['code'], @@ -75,9 +125,6 @@ const configuration: Configuration = { userinfo: { enabled: false }, // TODO: future Enable userinfo endpoint }, - // static clients via findAccount-style lookup - clients: staticClients, - // we don't use all of these but we prefix them to ensure our fn handles them routes: { authorization: authorizationEndpointPath, From 6a0770dd1bce677b1e32f3a27a42a773b213ae0f Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Tue, 21 Oct 2025 13:26:37 -0400 Subject: [PATCH 10/10] feat: handle dcr better --- netlify/functions/oauth-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netlify/functions/oauth-server.ts b/netlify/functions/oauth-server.ts index 8fcc3bc..16202f1 100644 --- a/netlify/functions/oauth-server.ts +++ b/netlify/functions/oauth-server.ts @@ -21,7 +21,7 @@ const staticClients: ClientMetadata[] = [ // AzureToolsCatalog@microsoft.com { client_id: 'azure-ai-foundry', - client_secret: Netlify.env.get('CLIENT_SECRET_AZURE_AI_FOUNDRY'), // Use secure secrets in production + client_secret: process.env.CLIENT_SECRET_AZURE_AI_FOUNDRY || 'supersecret!!!!!321aasdf23123cdfdSDFSKL;;;8', // Use secure secrets in production redirect_uris: [ 'https://global.consent.azure-apim.net/redirect/foundrynetlifymcp', 'https://global-test.consent.azure-apim.net/redirect/foundrynetlifymcp'