Skip to content
3 changes: 3 additions & 0 deletions packages/pwa-kit-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## v0.4.0-dev (Sep 30, 2025)
- Unexposed the extra parameters on create_page tool. [#3359] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3359)

## v0.4.0-dev (Sep 26, 2025)

## v0.3.0 (Sep 25, 2025)
Expand Down
54 changes: 22 additions & 32 deletions packages/pwa-kit-mcp/src/tools/create-new-page-tool.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
isLocalSharedUIComponent,
isBaseComponent,
isSharedUIBaseComponent,
generateComponentImportStatement
generateComponentImportStatement,
detectWorkspacePaths
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wei-liu-sf yes - it is an internal util function

} from '../utils'
import {z} from 'zod'

Expand All @@ -25,11 +26,6 @@ const systemPromptForCreatePage = `You are a smart assistant that can use tools
- What is the name of the new page to create? \
- List the components to include on the page, separated by commas. Component names should be in PascalCase (e.g., Image, ProductView) \
- What is the URL route for this page? (e.g., /new-home, /my-products) \
- What is the absolute path to your node_modules directory? \
- What is the absolute path to your components directory? \
- What is the absolute path to your pages directory? \
- What is the absolute path to your routes.jsx file? \
- Is ccExtensibility.overridesDir set in your package.json? (true/false) \
Collect answers to these questions, then call the tool with the collected information as input parameters.`

const systemPromptForProductHook = `User has added the ProductView component to the new page. Please ask user: \
Expand Down Expand Up @@ -83,42 +79,36 @@ class CreateNewPageTool {
),
route: z
.string()
.describe('The URL route for this page (e.g., /new-home, /my-product-view)'),
nodeModulesPath: z.string().describe('The absolute path to the node_modules directory'),
componentsPath: z.string().describe('The absolute path to the components directory'),
pagesPath: z.string().describe('The absolute path to the pages directory'),
routesPath: z.string().describe('The absolute path to the routes.jsx file'),
hasOverridesDir: z
.boolean()
.describe('Whether ccExtensibility.overridesDir is set in package.json')
.describe('The URL route for this page (e.g., /new-home, /my-product-view)')
}
this.unfoundComponents = []

this.handler = async (args) => {
logMCPMessage(`------- Calling CreateNewPageTool handler`)
if (
!args ||
!args.pageName ||
!args.componentList ||
!args.route ||
!args.nodeModulesPath ||
!args.componentsPath ||
!args.pagesPath ||
!args.routesPath ||
args.hasOverridesDir === undefined
) {
if (!args || !args.pageName || !args.componentList || !args.route) {
return {
role: 'system',
content: [{type: 'text', text: systemPromptForCreatePage}]
}
}
return this.createPage(args.pageName, args.componentList, args.route, {
nodeModulesPath: args.nodeModulesPath,
componentsPath: args.componentsPath,
pagesPath: args.pagesPath,
routesPath: args.routesPath,
hasOverridesDir: args.hasOverridesDir
})

try {
const absolutePaths = await detectWorkspacePaths()
logMCPMessage(`Detected workspace paths: ${JSON.stringify(absolutePaths)}`)

return this.createPage(args.pageName, args.componentList, args.route, absolutePaths)
} catch (error) {
logMCPMessage(`Error detecting workspace paths: ${error.message}`)
return {
role: 'developer',
content: [
{
type: 'text',
text: `Error detecting workspace configuration: ${error.message}`
}
]
}
}
}
}

Expand Down
117 changes: 107 additions & 10 deletions packages/pwa-kit-mcp/src/tools/create-new-page-tool.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,12 @@ describe('CreateNewPageTool', () => {
jest.spyOn(createNewPageTool, 'generatePageContent').mockResolvedValue('test content')
jest.spyOn(createNewPageTool, 'updateRoutes').mockResolvedValue()
jest.spyOn(utils, 'logMCPMessage').mockImplementation(() => {})
jest.spyOn(utils, 'detectWorkspacePaths').mockResolvedValue(mockAbsolutePaths)

const result = await createNewPageTool.handler({
pageName: 'Test',
componentList: ['Foo'],
route: '/test',
...mockAbsolutePaths
route: '/test'
})
expect(result.role).toBe('system')
expect(result.content[0].text).toContain('Created page')
Expand All @@ -55,11 +56,12 @@ describe('CreateNewPageTool', () => {
it('returns error if page already exists', async () => {
jest.spyOn(fs, 'access').mockResolvedValue()
jest.spyOn(utils, 'logMCPMessage').mockImplementation(() => {})
jest.spyOn(utils, 'detectWorkspacePaths').mockResolvedValue(mockAbsolutePaths)

const result = await createNewPageTool.handler({
pageName: 'Test',
componentList: ['Foo'],
route: '/test',
...mockAbsolutePaths
route: '/test'
})
expect(result.role).toBe('developer')
expect(result.content[0].text).toContain('Error creating page')
Expand All @@ -78,6 +80,7 @@ describe('CreateNewPageTool', () => {
jest.spyOn(fs, 'writeFile').mockResolvedValue()
jest.spyOn(createNewPageTool, 'updateRoutes').mockResolvedValue()
jest.spyOn(utils, 'logMCPMessage').mockImplementation(() => {})
jest.spyOn(utils, 'detectWorkspacePaths').mockResolvedValue(mockAbsolutePaths)
// Mock generatePageContent to simulate unfound component
jest.spyOn(createNewPageTool, 'generatePageContent').mockImplementation(function () {
this.unfoundComponents = ['MissingComponent']
Expand All @@ -86,8 +89,7 @@ describe('CreateNewPageTool', () => {
const result = await createNewPageTool.handler({
pageName: 'Test',
componentList: ['MissingComponent'],
route: '/test',
...mockAbsolutePaths
route: '/test'
})
expect(result.role).toBe('system')
expect(result.content[0].text).toContain('MissingComponent')
Expand All @@ -100,11 +102,12 @@ describe('CreateNewPageTool', () => {
jest.spyOn(createNewPageTool, 'generatePageContent').mockResolvedValue('dummy')
jest.spyOn(createNewPageTool, 'updateRoutes').mockResolvedValue()
jest.spyOn(utils, 'logMCPMessage').mockImplementation(() => {})
jest.spyOn(utils, 'detectWorkspacePaths').mockResolvedValue(mockAbsolutePaths)

const result = await createNewPageTool.handler({
pageName: 'Test',
componentList: ['ProductView'],
route: '/test',
...mockAbsolutePaths
route: '/test'
})
expect(result.role).toBe('system')
expect(result.content[0].text).toContain(
Expand Down Expand Up @@ -209,15 +212,15 @@ describe('CreateNewPageTool', () => {
jest.spyOn(fs, 'writeFile').mockResolvedValue()
jest.spyOn(createNewPageTool, 'updateRoutes').mockResolvedValue()
jest.spyOn(utils, 'logMCPMessage').mockImplementation(() => {})
jest.spyOn(utils, 'detectWorkspacePaths').mockResolvedValue(mockAbsolutePaths)
jest.spyOn(createNewPageTool, 'generatePageContent').mockImplementation(function () {
this.unfoundComponents = ['ImageSpliter']
return Promise.resolve('dummy')
})
const result = await createNewPageTool.handler({
pageName: 'Test',
componentList: ['ImageSpliter'],
route: '/test',
...mockAbsolutePaths
route: '/test'
})
expect(result.role).toBe('system')
expect(result.content[0].text).toContain('ImageSpliter')
Expand Down Expand Up @@ -368,3 +371,97 @@ describe('updateRoutes route insertion', () => {
expect(newRouteIndex).toBeLessThan(existingRouteIndex)
})
})

describe('Cross-Project compatibility', () => {
let createNewPageTool

beforeEach(() => {
jest.clearAllMocks()
// eslint-disable-next-line @typescript-eslint/no-var-requires
createNewPageTool = require('./create-new-page-tool').default
})

it('should work with simplified 3 params only', async () => {
// mock detectWorkspacePaths to return valid paths
const utils = await import('../utils/utils')
const mockPaths = {
pagesPath: '/test/pages',
componentsPath: '/test/components',
routesPath: '/test/routes.jsx',
nodeModulesPath: '/test/node_modules',
hasOverridesDir: false
}
jest.spyOn(utils, 'detectWorkspacePaths').mockResolvedValue(mockPaths)

const args = {
pageName: 'TestPage',
componentList: ['Header', 'Footer'],
route: '/test-page'
}

const result = await createNewPageTool.handler(args)

expect(result.role).toBe('system')
expect(result.content[0].text).toContain('Created page TestPage')
expect(result.content[0].text).toContain('Added route /test-page')
})

it('should return system prompt when parameters are missing', async () => {
const args = {
pageName: 'TestPage'
// no componentList & route
}

const result = await createNewPageTool.handler(args)

expect(result.role).toBe('system')
expect(result.content[0].text).toContain(
'Please ask the user to provide following information'
)
})

it('should handle customer project paths correctly', async () => {
// mock detectWorkspacePaths to return generated project structure
const utils = await import('../utils/utils')
const customerPaths = {
pagesPath: '/Users/customer/retail-react-app/overrides/app/pages',
componentsPath: '/Users/customer/retail-react-app/overrides/app/components',
routesPath: '/Users/customer/retail-react-app/overrides/app/routes.jsx',
nodeModulesPath: '/Users/customer/retail-react-app/node_modules',
hasOverridesDir: true
}
jest.spyOn(utils, 'detectWorkspacePaths').mockResolvedValue(customerPaths)

const args = {
pageName: 'CustomerPage',
componentList: ['Header'],
route: '/customer-page'
}

const result = await createNewPageTool.handler(args)

expect(result.role).toBe('system')
expect(result.content[0].text).toContain('Created page CustomerPage')
expect(utils.detectWorkspacePaths).toHaveBeenCalled()
})

it('should handle path detection errors gracefully', async () => {
// mock detectWorkspacePaths to throw an error
const utils = await import('../utils/utils')
jest.spyOn(utils, 'detectWorkspacePaths').mockRejectedValue(
new Error('PWA_STOREFRONT_APP_PATH does not exist: /invalid/path')
)

const args = {
pageName: 'TestPage',
componentList: ['Header'],
route: '/test-page'
}

const result = await createNewPageTool.handler(args)

expect(result.role).toBe('developer')
expect(result.content[0].text).toContain('Error detecting workspace configuration')
expect(result.content[0].text).toContain('PWA_STOREFRONT_APP_PATH does not exist')
})
})
10 changes: 5 additions & 5 deletions packages/pwa-kit-mcp/src/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ import {
toKebabCase,
toPascalCase,
logMCPMessage,
getCopyrightHeader,
isBaseComponent,
isSharedUIBaseComponent,
isLocalComponent,
isLocalSharedUIComponent,
generateComponentImportStatement
} from './utils'
generateComponentImportStatement,
detectWorkspacePaths
} from './utils.js'

export {
EmptyJsonSchema,
Expand All @@ -28,10 +28,10 @@ export {
toKebabCase,
toPascalCase,
logMCPMessage,
getCopyrightHeader,
isBaseComponent,
isSharedUIBaseComponent,
isLocalComponent,
isLocalSharedUIComponent,
generateComponentImportStatement
generateComponentImportStatement,
detectWorkspacePaths
}
52 changes: 52 additions & 0 deletions packages/pwa-kit-mcp/src/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,58 @@ export async function logMCPMessage(message) {
}
}

/**
* Detects workspace paths based on current working directory
* @returns {Object} containing detected absolute paths & configuration
*/
export async function detectWorkspacePaths() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use cwd first? Most likely it will work.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to figure out project directory from: 1. cwd, 2. from env 3. prompt user

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it, thx

// Use PWA_STOREFRONT_APP_PATH (always provided by MCP configuration)
const appPath = process.env.PWA_STOREFRONT_APP_PATH
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How to make sure that this will always be set

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


if (!appPath) {
Copy link
Contributor

@aditek-sf aditek-sf Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snilakandan13 the error is thrown in case when the env variable is not set. However if not set, the server starts successfully, but the tool call fails. As discussed maybe we should not let the server start at all if this is not set.

throw new Error(
'PWA_STOREFRONT_APP_PATH environment variable is not set. Please check your MCP configuration.'
)
}

// Verify the provided app path exists
try {
await fsPromises.access(appPath)
} catch (error) {
throw new Error(`PWA_STOREFRONT_APP_PATH does not exist: ${appPath}`)
}

// Build paths relative to the provided app directory
const pagesPath = path.join(appPath, 'pages')
const componentsPath = path.join(appPath, 'components')
const routesPath = path.join(appPath, 'routes.jsx')

// Node modules is typically one level up from the app directory
const nodeModulesPath = path.join(appPath, '..', 'node_modules')

// Check if overrides directory exists (for ccExtensibility.overridesDir)
const hasOverridesDir = fs.existsSync(path.join(appPath, '..', 'overrides'))

// Verify essential directories exist
if (!fs.existsSync(pagesPath)) {
throw new Error(`Pages directory not found at: ${pagesPath}`)
}
if (!fs.existsSync(componentsPath)) {
throw new Error(`Components directory not found at: ${componentsPath}`)
}
if (!fs.existsSync(routesPath)) {
throw new Error(`Routes file not found at: ${routesPath}`)
}

return {
pagesPath,
componentsPath,
routesPath,
nodeModulesPath,
hasOverridesDir
}
}

/**
* Returns the import statement for a component
* @param {string} componentName - The name of the component to import.
Expand Down
Loading
Loading