This is page 2 of 2. Use http://codebase.md/ivo-toby/contentful-mcp?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── workflows
│ ├── pr-check.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── .prettierrc
├── .releaserc
├── bin
│ └── mcp-server.js
├── build.js
├── CLAUDE.md
├── codecompanion-workspace.json
├── Dockerfile
├── eslint.config.js
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── inspect-watch.js
│ └── inspect.js
├── smithery.yaml
├── src
│ ├── config
│ │ ├── ai-actions-client.ts
│ │ └── client.ts
│ ├── handlers
│ │ ├── ai-action-handlers.ts
│ │ ├── asset-handlers.ts
│ │ ├── bulk-action-handlers.ts
│ │ ├── comment-handlers.ts
│ │ ├── content-type-handlers.ts
│ │ ├── entry-handlers.ts
│ │ └── space-handlers.ts
│ ├── index.ts
│ ├── prompts
│ │ ├── ai-actions-invoke.ts
│ │ ├── ai-actions-overview.ts
│ │ ├── contentful-prompts.ts
│ │ ├── generateVariableTypeContent.ts
│ │ ├── handlePrompt.ts
│ │ ├── handlers.ts
│ │ └── promptHandlers
│ │ ├── aiActions.ts
│ │ └── contentful.ts
│ ├── transports
│ │ ├── sse.ts
│ │ └── streamable-http.ts
│ ├── types
│ │ ├── ai-actions.ts
│ │ └── tools.ts
│ └── utils
│ ├── ai-action-tool-generator.ts
│ ├── summarizer.ts
│ ├── to-camel-case.ts
│ └── validation.ts
├── test
│ ├── integration
│ │ ├── ai-action-handler.test.ts
│ │ ├── ai-actions-client.test.ts
│ │ ├── asset-handler.test.ts
│ │ ├── bulk-action-handler.test.ts
│ │ ├── client.test.ts
│ │ ├── comment-handler.test.ts
│ │ ├── content-type-handler.test.ts
│ │ ├── entry-handler.test.ts
│ │ ├── space-handler.test.ts
│ │ └── streamable-http.test.ts
│ ├── msw-setup.ts
│ ├── setup.ts
│ └── unit
│ ├── ai-action-header.test.ts
│ ├── ai-action-tool-generator.test.ts
│ ├── ai-action-tools.test.ts
│ ├── ai-actions.test.ts
│ ├── content-type-handler-merge.test.ts
│ ├── entry-handler-merge.test.ts
│ └── tools.test.ts
├── tsconfig.json
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/test/integration/ai-action-handler.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest"
import { aiActionHandlers } from "../../src/handlers/ai-action-handlers"
import { aiActionsClient } from "../../src/config/ai-actions-client"
// Mock the AI Actions client
vi.mock("../../src/config/ai-actions-client", () => ({
aiActionsClient: {
listAiActions: vi.fn(),
getAiAction: vi.fn(),
createAiAction: vi.fn(),
updateAiAction: vi.fn(),
deleteAiAction: vi.fn(),
publishAiAction: vi.fn(),
unpublishAiAction: vi.fn(),
invokeAiAction: vi.fn(),
getAiActionInvocation: vi.fn(),
pollInvocation: vi.fn(),
},
}))
// Mock data
const mockAiAction = {
sys: {
id: "action1",
type: "AiAction" as const,
createdAt: "2023-01-01T00:00:00Z",
updatedAt: "2023-01-02T00:00:00Z",
version: 1,
space: { sys: { id: "space1", linkType: "Space", type: "Link" as const } },
createdBy: { sys: { id: "user1", linkType: "User", type: "Link" as const } },
updatedBy: { sys: { id: "user1", linkType: "User", type: "Link" as const } },
},
name: "Test Action",
description: "A test action",
instruction: {
template: "Template with {{var}}",
variables: [{ id: "var", type: "Text" }],
},
configuration: {
modelType: "gpt-4",
modelTemperature: 0.5,
},
}
const mockAiActionCollection = {
sys: { type: "Array" as const },
items: [mockAiAction],
total: 1,
skip: 0,
limit: 10,
}
const mockInvocation = {
sys: {
id: "inv1",
type: "AiActionInvocation" as const,
space: { sys: { id: "space1", linkType: "Space", type: "Link" as const } },
environment: { sys: { id: "master", linkType: "Environment", type: "Link" as const } },
aiAction: { sys: { id: "action1", linkType: "AiAction", type: "Link" as const } },
status: "COMPLETED" as const,
},
result: {
type: "text" as const,
content: "Generated content",
metadata: {
invocationResult: {
aiAction: {
sys: {
id: "action1",
linkType: "AiAction",
type: "Link" as const,
version: 1,
},
},
outputFormat: "PlainText" as const,
promptTokens: 50,
completionTokens: 100,
modelId: "gpt-4",
modelProvider: "OpenAI",
},
},
},
}
describe("AI Action Handlers", () => {
beforeEach(() => {
vi.resetAllMocks()
})
it("should list AI Actions", async () => {
const clientSpy = vi
.mocked(aiActionsClient.listAiActions)
.mockResolvedValueOnce(mockAiActionCollection)
const result = await aiActionHandlers.listAiActions({
spaceId: "space1",
limit: 10,
})
expect(clientSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
limit: 10,
skip: undefined,
status: undefined,
})
expect(result).toEqual(mockAiActionCollection)
})
it("should get an AI Action", async () => {
const clientSpy = vi.mocked(aiActionsClient.getAiAction).mockResolvedValueOnce(mockAiAction)
const result = await aiActionHandlers.getAiAction({
spaceId: "space1",
aiActionId: "action1",
})
expect(clientSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
aiActionId: "action1",
})
expect(result).toEqual(mockAiAction)
})
it("should create an AI Action", async () => {
const clientSpy = vi.mocked(aiActionsClient.createAiAction).mockResolvedValueOnce(mockAiAction)
const actionData = {
spaceId: "space1",
name: "New Action",
description: "A new action",
instruction: {
template: "Template",
variables: [],
},
configuration: {
modelType: "gpt-4",
modelTemperature: 0.5,
},
}
const result = await aiActionHandlers.createAiAction(actionData)
expect(clientSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
actionData: {
name: "New Action",
description: "A new action",
instruction: {
template: "Template",
variables: [],
},
configuration: {
modelType: "gpt-4",
modelTemperature: 0.5,
},
testCases: undefined,
},
})
expect(result).toEqual(mockAiAction)
})
it("should update an AI Action", async () => {
vi.mocked(aiActionsClient.getAiAction).mockResolvedValueOnce(mockAiAction)
const updateSpy = vi.mocked(aiActionsClient.updateAiAction).mockResolvedValueOnce({
...mockAiAction,
name: "Updated Action",
})
const actionData = {
spaceId: "space1",
aiActionId: "action1",
name: "Updated Action",
description: "An updated action",
instruction: {
template: "Updated template",
variables: [],
},
configuration: {
modelType: "gpt-4",
modelTemperature: 0.7,
},
}
const result = await aiActionHandlers.updateAiAction(actionData)
expect(updateSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
aiActionId: "action1",
version: 1,
actionData: {
name: "Updated Action",
description: "An updated action",
instruction: {
template: "Updated template",
variables: [],
},
configuration: {
modelType: "gpt-4",
modelTemperature: 0.7,
},
testCases: undefined,
},
})
expect(result).toEqual({
...mockAiAction,
name: "Updated Action",
})
})
it("should delete an AI Action", async () => {
vi.mocked(aiActionsClient.getAiAction).mockResolvedValueOnce(mockAiAction)
const deleteSpy = vi.mocked(aiActionsClient.deleteAiAction).mockResolvedValueOnce()
const result = await aiActionHandlers.deleteAiAction({
spaceId: "space1",
aiActionId: "action1",
})
expect(deleteSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
aiActionId: "action1",
version: 1,
})
expect(result).toEqual({ success: true })
})
it("should publish an AI Action", async () => {
vi.mocked(aiActionsClient.getAiAction).mockResolvedValueOnce(mockAiAction)
const publishSpy = vi.mocked(aiActionsClient.publishAiAction).mockResolvedValueOnce({
...mockAiAction,
sys: {
...mockAiAction.sys,
publishedAt: "2023-01-03T00:00:00Z",
publishedVersion: 1,
},
})
const result = await aiActionHandlers.publishAiAction({
spaceId: "space1",
aiActionId: "action1",
})
expect(publishSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
aiActionId: "action1",
version: 1,
})
expect(result).toHaveProperty("sys.publishedAt", "2023-01-03T00:00:00Z")
})
it("should unpublish an AI Action", async () => {
const unpublishSpy = vi
.mocked(aiActionsClient.unpublishAiAction)
.mockResolvedValueOnce(mockAiAction)
const result = await aiActionHandlers.unpublishAiAction({
spaceId: "space1",
aiActionId: "action1",
})
expect(unpublishSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
aiActionId: "action1",
})
expect(result).toEqual(mockAiAction)
})
it("should invoke an AI Action with key-value variables", async () => {
const invokeSpy = vi
.mocked(aiActionsClient.invokeAiAction)
.mockResolvedValueOnce(mockInvocation)
const result = await aiActionHandlers.invokeAiAction({
spaceId: "space1",
aiActionId: "action1",
variables: {
var1: "value1",
var2: "value2",
},
})
expect(invokeSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
aiActionId: "action1",
invocationData: {
outputFormat: "Markdown",
variables: [
{ id: "var1", value: "value1" },
{ id: "var2", value: "value2" },
],
},
})
expect(result).toEqual(mockInvocation)
})
it("should invoke an AI Action with raw variables", async () => {
const invokeSpy = vi
.mocked(aiActionsClient.invokeAiAction)
.mockResolvedValueOnce(mockInvocation)
const rawVariables = [
{
id: "refVar",
value: {
entityType: "Entry",
entityId: "entry123",
},
},
]
const result = await aiActionHandlers.invokeAiAction({
spaceId: "space1",
aiActionId: "action1",
rawVariables,
})
expect(invokeSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
aiActionId: "action1",
invocationData: {
outputFormat: "Markdown",
variables: rawVariables,
},
})
expect(result).toEqual(mockInvocation)
})
it("should poll for completion when invoking asynchronously", async () => {
const inProgressInvocation = {
...mockInvocation,
sys: {
...mockInvocation.sys,
status: "IN_PROGRESS",
},
result: undefined,
}
vi.mocked(aiActionsClient.invokeAiAction).mockResolvedValueOnce(inProgressInvocation)
vi.mocked(aiActionsClient.pollInvocation).mockResolvedValueOnce(mockInvocation)
const result = await aiActionHandlers.invokeAiAction({
spaceId: "space1",
aiActionId: "action1",
variables: { var: "value" },
waitForCompletion: true,
})
expect(aiActionsClient.pollInvocation).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
aiActionId: "action1",
invocationId: "inv1",
})
expect(result).toEqual(mockInvocation)
})
it("should not poll when waitForCompletion is false", async () => {
const inProgressInvocation = {
...mockInvocation,
sys: {
...mockInvocation.sys,
status: "IN_PROGRESS",
},
result: undefined,
}
vi.mocked(aiActionsClient.invokeAiAction).mockResolvedValueOnce(inProgressInvocation)
const result = await aiActionHandlers.invokeAiAction({
spaceId: "space1",
aiActionId: "action1",
variables: { var: "value" },
waitForCompletion: false,
})
expect(aiActionsClient.pollInvocation).not.toHaveBeenCalled()
expect(result).toEqual(inProgressInvocation)
})
it("should get an AI Action invocation", async () => {
const getSpy = vi
.mocked(aiActionsClient.getAiActionInvocation)
.mockResolvedValueOnce(mockInvocation)
const result = await aiActionHandlers.getAiActionInvocation({
spaceId: "space1",
aiActionId: "action1",
invocationId: "inv1",
})
expect(getSpy).toHaveBeenCalledWith({
spaceId: "space1",
environmentId: "master",
aiActionId: "action1",
invocationId: "inv1",
})
expect(result).toEqual(mockInvocation)
})
it("should handle errors correctly", async () => {
vi.mocked(aiActionsClient.getAiAction).mockRejectedValueOnce({
message: "Not found",
response: {
data: {
message: "AI Action not found",
},
},
})
const result = await aiActionHandlers.getAiAction({
spaceId: "space1",
aiActionId: "nonexistent",
})
expect(result).toEqual({
isError: true,
message: "AI Action not found",
})
})
})
```
--------------------------------------------------------------------------------
/test/integration/ai-actions-client.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest"
// Mock the client module first, before any other imports
vi.mock("../../src/config/client", () => {
// Create mock functions for the raw client
const mockRawClient = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn()
}
return {
getContentfulClient: vi.fn().mockResolvedValue({
raw: mockRawClient
})
}
})
// Now import the client that will use the mocked getContentfulClient
import { aiActionsClient } from "../../src/config/ai-actions-client"
import { getContentfulClient } from "../../src/config/client"
// Constants for alpha header (for test assertions)
const ALPHA_HEADER_NAME = 'X-Contentful-Enable-Alpha-Feature'
const ALPHA_HEADER_VALUE = 'ai-service'
// Mock data with const assertions for TypeScript
const mockAiAction = {
sys: {
id: "mockActionId",
type: "AiAction" as const,
createdAt: "2023-01-01T00:00:00Z",
updatedAt: "2023-01-02T00:00:00Z",
version: 1,
space: { sys: { id: "mockSpace", linkType: "Space", type: "Link" as const } },
createdBy: { sys: { id: "user1", linkType: "User", type: "Link" as const } },
updatedBy: { sys: { id: "user1", linkType: "User", type: "Link" as const } }
},
name: "Mock Action",
description: "A mock AI action",
instruction: {
template: "This is a template with {{variable}}",
variables: [
{ id: "variable", type: "Text" as const, name: "Variable" }
]
},
configuration: {
modelType: "gpt-4",
modelTemperature: 0.7
}
}
const mockAiActionCollection = {
sys: { type: "Array" as const },
items: [mockAiAction],
total: 1,
skip: 0,
limit: 10
}
const mockInvocation = {
sys: {
id: "mockInvocationId",
type: "AiActionInvocation" as const,
space: { sys: { id: "mockSpace", linkType: "Space", type: "Link" as const } },
environment: { sys: { id: "master", linkType: "Environment", type: "Link" as const } },
aiAction: { sys: { id: "mockActionId", linkType: "AiAction", type: "Link" as const } },
status: "COMPLETED" as const
},
result: {
type: "text" as const,
content: "Generated content",
metadata: {
invocationResult: {
aiAction: {
sys: {
id: "mockActionId",
linkType: "AiAction",
type: "Link" as const,
version: 1
}
},
outputFormat: "PlainText" as const,
promptTokens: 50,
completionTokens: 100,
modelId: "gpt-4",
modelProvider: "OpenAI"
}
}
}
}
describe("AI Actions Client", () => {
let mockClientRaw: any
beforeEach(async () => {
vi.clearAllMocks()
// Get the mocked client to work with in tests
const client = await getContentfulClient()
mockClientRaw = client.raw
})
it("should list AI Actions with alpha header", async () => {
mockClientRaw.get.mockResolvedValueOnce({ data: mockAiActionCollection })
const result = await aiActionsClient.listAiActions({ spaceId: "mockSpace" })
// Verify the alpha header was included
expect(mockClientRaw.get).toHaveBeenCalledWith(
"/spaces/mockSpace/environments/master/ai/actions?limit=100&skip=0",
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE
})
})
)
expect(result.items).toHaveLength(1)
expect(result.items[0].name).toBe("Mock Action")
})
it("should get an AI Action with alpha header", async () => {
mockClientRaw.get.mockResolvedValueOnce({ data: mockAiAction })
const result = await aiActionsClient.getAiAction({
spaceId: "mockSpace",
aiActionId: "mockActionId"
})
expect(mockClientRaw.get).toHaveBeenCalledWith(
"/spaces/mockSpace/environments/master/ai/actions/mockActionId",
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE
})
})
)
expect(result.name).toBe("Mock Action")
})
it("should create an AI Action with alpha header", async () => {
mockClientRaw.post.mockResolvedValueOnce({ data: mockAiAction })
const actionData = {
name: "New Action",
description: "A new AI action",
instruction: {
template: "Template",
variables: []
},
configuration: {
modelType: "gpt-4",
modelTemperature: 0.5
}
}
const result = await aiActionsClient.createAiAction({
spaceId: "mockSpace",
actionData
})
expect(mockClientRaw.post).toHaveBeenCalledWith(
"/spaces/mockSpace/environments/master/ai/actions",
actionData,
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE
})
})
)
expect(result.name).toBe("Mock Action")
})
it("should update an AI Action with alpha header", async () => {
mockClientRaw.get.mockResolvedValueOnce({ data: mockAiAction })
mockClientRaw.put.mockResolvedValueOnce({
data: {
...mockAiAction,
name: "Updated Action"
}
})
const actionData = {
name: "Updated Action",
description: "An updated AI action",
instruction: {
template: "Updated template",
variables: []
},
configuration: {
modelType: "gpt-4",
modelTemperature: 0.7
}
}
const result = await aiActionsClient.updateAiAction({
spaceId: "mockSpace",
aiActionId: "mockActionId",
version: 1,
actionData
})
expect(mockClientRaw.put).toHaveBeenCalledWith(
"/spaces/mockSpace/environments/master/ai/actions/mockActionId",
actionData,
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE,
"X-Contentful-Version": "1"
})
})
)
expect(result).toHaveProperty("name", "Updated Action")
})
it("should delete an AI Action with alpha header", async () => {
mockClientRaw.get.mockResolvedValueOnce({ data: mockAiAction })
mockClientRaw.delete.mockResolvedValueOnce({})
await aiActionsClient.deleteAiAction({
spaceId: "mockSpace",
aiActionId: "mockActionId",
version: 1
})
expect(mockClientRaw.delete).toHaveBeenCalledWith(
"/spaces/mockSpace/environments/master/ai/actions/mockActionId",
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE,
"X-Contentful-Version": "1"
})
})
)
})
it("should publish an AI Action with alpha header", async () => {
mockClientRaw.get.mockResolvedValueOnce({ data: mockAiAction })
mockClientRaw.put.mockResolvedValueOnce({
data: {
...mockAiAction,
sys: {
...mockAiAction.sys,
publishedAt: "2023-01-03T00:00:00Z",
publishedVersion: 1,
publishedBy: { sys: { id: "user1", linkType: "User", type: "Link" as const } }
}
}
})
const result = await aiActionsClient.publishAiAction({
spaceId: "mockSpace",
aiActionId: "mockActionId",
version: 1
})
expect(mockClientRaw.put).toHaveBeenCalledWith(
"/spaces/mockSpace/environments/master/ai/actions/mockActionId/published",
{},
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE,
"X-Contentful-Version": "1"
})
})
)
expect(result.sys).toHaveProperty("publishedAt", "2023-01-03T00:00:00Z")
})
it("should unpublish an AI Action with alpha header", async () => {
mockClientRaw.delete.mockResolvedValueOnce({ data: mockAiAction })
const result = await aiActionsClient.unpublishAiAction({
spaceId: "mockSpace",
aiActionId: "mockActionId"
})
expect(mockClientRaw.delete).toHaveBeenCalledWith(
"/spaces/mockSpace/environments/master/ai/actions/mockActionId/published",
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE
})
})
)
expect(result.name).toBe("Mock Action")
})
it("should invoke an AI Action with alpha header", async () => {
mockClientRaw.post.mockResolvedValueOnce({ data: mockInvocation })
const invocationData = {
outputFormat: "PlainText" as const,
variables: [{ id: "variable", value: "test" }]
}
const result = await aiActionsClient.invokeAiAction({
spaceId: "mockSpace",
aiActionId: "mockActionId",
invocationData
})
expect(mockClientRaw.post).toHaveBeenCalledWith(
"/spaces/mockSpace/environments/master/ai/actions/mockActionId/invoke",
invocationData,
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE,
"X-Contentful-Include-Invocation-Metadata": "true"
})
})
)
expect(result.sys.status).toBe("COMPLETED")
expect(result.result?.content).toBe("Generated content")
})
it("should get an AI Action invocation with alpha header", async () => {
// Make sure we reset the mock to avoid any previous mock calls affecting this test
mockClientRaw.get.mockReset()
// Mock with the correct invocation response
mockClientRaw.get.mockResolvedValueOnce({ data: mockInvocation })
const result = await aiActionsClient.getAiActionInvocation({
spaceId: "mockSpace",
aiActionId: "mockActionId",
invocationId: "mockInvocationId"
})
// Verify the correct API endpoint was called with the alpha header
expect(mockClientRaw.get).toHaveBeenCalledWith(
"/spaces/mockSpace/environments/master/ai/actions/mockActionId/invocations/mockInvocationId",
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE,
"X-Contentful-Include-Invocation-Metadata": "true"
})
})
)
// Just check critical properties rather than exact equality
expect(result.sys.id).toBe("mockInvocationId")
expect(result.sys.type).toBe("AiActionInvocation")
expect(result.sys.status).toBe("COMPLETED")
expect(result.result?.content).toBe("Generated content")
})
it("should poll an AI Action invocation with alpha header", async () => {
// Reset mock call count
mockClientRaw.get.mockReset()
// First call returns IN_PROGRESS status
mockClientRaw.get.mockResolvedValueOnce({
data: {
...mockInvocation,
sys: {
...mockInvocation.sys,
status: "IN_PROGRESS" as const
},
result: undefined
}
})
// Second call returns COMPLETED status
mockClientRaw.get.mockResolvedValueOnce({ data: mockInvocation })
// Spy on setTimeout to avoid actual waiting
vi.spyOn(global, "setTimeout").mockImplementation((callback: any) => {
callback()
return {} as any
})
const result = await aiActionsClient.pollInvocation({
spaceId: "mockSpace",
aiActionId: "mockActionId",
invocationId: "mockInvocationId"
}, 2, 100, 100)
// Verify both API calls included the alpha header
expect(mockClientRaw.get).toHaveBeenCalledTimes(2)
expect(mockClientRaw.get).toHaveBeenNthCalledWith(
1,
"/spaces/mockSpace/environments/master/ai/actions/mockActionId/invocations/mockInvocationId",
expect.objectContaining({
headers: expect.objectContaining({
[ALPHA_HEADER_NAME]: ALPHA_HEADER_VALUE,
"X-Contentful-Include-Invocation-Metadata": "true"
})
})
)
expect(result.sys.status).toBe("COMPLETED")
expect(result.result?.content).toBe("Generated content")
vi.restoreAllMocks()
})
})
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { CONTENTFUL_PROMPTS } from "./prompts/contentful-prompts.js"
export { CONTENTFUL_PROMPTS }
import { handlePrompt } from "./prompts/handlers.js"
import { entryHandlers } from "./handlers/entry-handlers.js"
import { assetHandlers } from "./handlers/asset-handlers.js"
import { spaceHandlers } from "./handlers/space-handlers.js"
import { contentTypeHandlers } from "./handlers/content-type-handlers.js"
import { bulkActionHandlers } from "./handlers/bulk-action-handlers.js"
import { aiActionHandlers } from "./handlers/ai-action-handlers.js"
import { commentHandlers } from "./handlers/comment-handlers.js"
import { getTools } from "./types/tools.js"
import { validateEnvironment } from "./utils/validation.js"
import { AiActionToolContext } from "./utils/ai-action-tool-generator.js"
import type { AiActionInvocation } from "./types/ai-actions.js"
import { StreamableHttpServer } from "./transports/streamable-http.js"
// Validate environment variables
validateEnvironment()
// Create AI Action tool context
const aiActionToolContext = new AiActionToolContext(
process.env.SPACE_ID || "",
process.env.ENVIRONMENT_ID || "master",
)
// Function to get all tools including dynamic AI Action tools
export function getAllTools() {
const staticTools = getTools()
// Add dynamically generated tools for AI Actions
const dynamicTools = aiActionToolContext.generateAllToolSchemas()
return {
...staticTools,
...dynamicTools.reduce(
(acc, tool) => {
acc[tool.name] = tool
return acc
},
{} as Record<string, unknown>,
),
}
}
// Create MCP server
const server = new Server(
{
name: "contentful-mcp-server",
version: "1.15.0",
},
{
capabilities: {
tools: getAllTools(),
prompts: CONTENTFUL_PROMPTS,
},
},
)
// Set up request handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
// Return both static and dynamic tools
return {
tools: Object.values(getAllTools()),
}
})
// Set up request handlers
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
prompts: Object.values(CONTENTFUL_PROMPTS),
}))
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params
return handlePrompt(name, args)
})
// Type-safe handler
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
server.setRequestHandler(CallToolRequestSchema, async (request, _extra): Promise<any> => {
try {
const { name, arguments: args } = request.params
const handler = getHandler(name)
if (!handler) {
throw new Error(`Unknown tool: ${name}`)
}
const result = await handler(args || {})
// For AI Action responses, format them appropriately
if (result && typeof result === "object") {
// Check if this is an AI Action invocation result
if (
"sys" in result &&
typeof result.sys === "object" &&
result.sys &&
"type" in result.sys &&
result.sys.type === "AiActionInvocation"
) {
const invocationResult = result as AiActionInvocation
// Format AI Action result as text content if available
if (invocationResult.result && invocationResult.result.content) {
return {
content: [
{
type: "text",
text:
typeof invocationResult.result.content === "string"
? invocationResult.result.content
: JSON.stringify(invocationResult.result.content),
},
],
}
}
}
// Check for error response
if ("isError" in result && result.isError === true) {
// Format error response
return {
content: [
{
type: "text",
text: "message" in result ? String(result.message) : "Unknown error",
},
],
isError: true,
}
}
}
// Return the result as is for regular handlers
return result
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
}
}
})
// Helper function to map tool names to handlers
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getHandler(name: string): ((args: any) => Promise<any>) | undefined {
// Check if this is a dynamic AI Action tool
if (name.startsWith("ai_action_")) {
const actionId = name.replace("ai_action_", "")
return (args: Record<string, unknown>) => handleAiActionInvocation(actionId, args)
}
const handlers = {
// Entry operations
create_entry: entryHandlers.createEntry,
get_entry: entryHandlers.getEntry,
update_entry: entryHandlers.updateEntry,
delete_entry: entryHandlers.deleteEntry,
publish_entry: entryHandlers.publishEntry,
unpublish_entry: entryHandlers.unpublishEntry,
search_entries: entryHandlers.searchEntries,
// Bulk operations
bulk_publish: bulkActionHandlers.bulkPublish,
bulk_unpublish: bulkActionHandlers.bulkUnpublish,
bulk_validate: bulkActionHandlers.bulkValidate,
// Asset operations
upload_asset: assetHandlers.uploadAsset,
get_asset: assetHandlers.getAsset,
update_asset: assetHandlers.updateAsset,
delete_asset: assetHandlers.deleteAsset,
publish_asset: assetHandlers.publishAsset,
unpublish_asset: assetHandlers.unpublishAsset,
list_assets: assetHandlers.listAssets,
// Space & Environment operations
list_spaces: spaceHandlers.listSpaces,
get_space: spaceHandlers.getSpace,
list_environments: spaceHandlers.listEnvironments,
create_environment: spaceHandlers.createEnvironment,
delete_environment: spaceHandlers.deleteEnvironment,
// Content Type operations
list_content_types: contentTypeHandlers.listContentTypes,
get_content_type: contentTypeHandlers.getContentType,
create_content_type: contentTypeHandlers.createContentType,
update_content_type: contentTypeHandlers.updateContentType,
delete_content_type: contentTypeHandlers.deleteContentType,
publish_content_type: contentTypeHandlers.publishContentType,
// AI Action operations
list_ai_actions: aiActionHandlers.listAiActions,
get_ai_action: aiActionHandlers.getAiAction,
create_ai_action: aiActionHandlers.createAiAction,
update_ai_action: aiActionHandlers.updateAiAction,
delete_ai_action: aiActionHandlers.deleteAiAction,
publish_ai_action: aiActionHandlers.publishAiAction,
unpublish_ai_action: aiActionHandlers.unpublishAiAction,
invoke_ai_action: aiActionHandlers.invokeAiAction,
get_ai_action_invocation: aiActionHandlers.getAiActionInvocation,
// Comment operations
get_comments: commentHandlers.getComments,
create_comment: commentHandlers.createComment,
get_single_comment: commentHandlers.getSingleComment,
delete_comment: commentHandlers.deleteComment,
update_comment: commentHandlers.updateComment,
}
return handlers[name as keyof typeof handlers]
}
// Handler for dynamic AI Action tools
async function handleAiActionInvocation(actionId: string, args: Record<string, unknown>) {
try {
console.error(`Handling AI Action invocation for ${actionId} with args:`, JSON.stringify(args))
// Get the parameters using the updated getInvocationParams
const params = aiActionToolContext.getInvocationParams(actionId, args)
// Directly use the variables property from getInvocationParams
const invocationParams = {
spaceId: params.spaceId,
environmentId: params.environmentId,
aiActionId: params.aiActionId,
outputFormat: params.outputFormat,
waitForCompletion: params.waitForCompletion,
// Use the correctly formatted variables array directly
rawVariables: params.variables,
}
console.error(`Invoking AI Action with params:`, JSON.stringify(invocationParams))
// Invoke the AI Action
return aiActionHandlers.invokeAiAction(invocationParams)
} catch (error) {
console.error(`Error invoking AI Action:`, error)
return {
isError: true,
message: error instanceof Error ? error.message : String(error),
}
}
}
// Functions to initialize and refresh AI Actions
async function loadAiActions() {
try {
// First, clear the cache to avoid duplicates
aiActionToolContext.clearCache()
// Only load AI Actions if we have required space and environment
if (!process.env.SPACE_ID) {
return
}
// Fetch published AI Actions
const response = await aiActionHandlers.listAiActions({
spaceId: process.env.SPACE_ID,
environmentId: process.env.ENVIRONMENT_ID || "master",
status: "published",
})
// Check for errors or undefined response
if (!response) {
console.error("Error loading AI Actions: No response received")
return
}
if (typeof response === "object" && "isError" in response) {
console.error(`Error loading AI Actions: ${response.message}`)
return
}
// Add each AI Action to the context
for (const action of response.items) {
aiActionToolContext.addAiAction(action)
// Log variable mappings for debugging
if (action.instruction.variables && action.instruction.variables.length > 0) {
// Log ID mappings
const idMappings = aiActionToolContext.getIdMappings(action.sys.id)
if (idMappings && idMappings.size > 0) {
const mappingLog = Array.from(idMappings.entries())
.map(([friendly, original]) => `${friendly} -> ${original}`)
.join(", ")
console.error(`AI Action ${action.name} - Parameter mappings: ${mappingLog}`)
}
// Log path mappings
const pathMappings = aiActionToolContext.getPathMappings(action.sys.id)
if (pathMappings && pathMappings.size > 0) {
const pathMappingLog = Array.from(pathMappings.entries())
.map(([friendly, original]) => `${friendly} -> ${original}`)
.join(", ")
console.error(`AI Action ${action.name} - Path parameter mappings: ${pathMappingLog}`)
}
}
}
console.error(`Loaded ${response.items.length} AI Actions`)
} catch (error) {
console.error("Error loading AI Actions:", error)
}
}
// Start the server
async function runServer() {
// Determine if HTTP server mode is enabled
const enableHttp = process.env.ENABLE_HTTP_SERVER === "true"
const httpPort = process.env.HTTP_PORT ? parseInt(process.env.HTTP_PORT) : 3000
// Load AI Actions before connecting
await loadAiActions()
if (enableHttp) {
// Start StreamableHTTP server for MCP over HTTP
const httpServer = new StreamableHttpServer({
port: httpPort,
host: process.env.HTTP_HOST || "localhost",
})
await httpServer.start()
console.error(
`Contentful MCP Server running with StreamableHTTP on port ${httpPort} using contentful host ${process.env.CONTENTFUL_HOST}`,
)
// Keep the process running
process.on("SIGINT", async () => {
console.error("Shutting down HTTP server...")
await httpServer.stop()
process.exit(0)
})
} else {
// Traditional stdio mode
const transport = new StdioServerTransport()
// Connect to the server
await server.connect(transport)
console.error(
`Contentful MCP Server running on stdio using contentful host ${process.env.CONTENTFUL_HOST}`,
)
}
// Set up periodic refresh of AI Actions (every 5 minutes)
setInterval(loadAiActions, 5 * 60 * 1000)
}
runServer().catch((error) => {
console.error("Fatal error running server:", error)
process.exit(1)
})
```
--------------------------------------------------------------------------------
/src/handlers/entry-handlers.ts:
--------------------------------------------------------------------------------
```typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getContentfulClient } from "../config/client.js"
import { summarizeData } from "../utils/summarizer.js"
import { CreateEntryProps, EntryProps, QueryOptions, BulkActionProps } from "contentful-management"
// Define the interface for bulk action responses with succeeded property
interface BulkActionResponse extends BulkActionProps<any> {
succeeded?: Array<{
sys: {
id: string
type: string
}
}>
}
// Define the interface for versioned links
interface VersionedLink {
sys: {
type: "Link"
linkType: "Entry" | "Asset"
id: string
version: number
}
}
export const entryHandlers = {
searchEntries: async (args: { spaceId: string; environmentId: string; query: QueryOptions }) => {
const spaceId = process.env.SPACE_ID || args.spaceId
const environmentId = process.env.ENVIRONMENT_ID || args.environmentId
const params = {
spaceId,
environmentId,
}
const contentfulClient = await getContentfulClient()
const entries = await contentfulClient.entry.getMany({
...params,
query: {
...args.query,
limit: Math.min(args.query.limit || 3, 3),
skip: args.query.skip || 0,
},
})
const summarized = summarizeData(entries, {
maxItems: 3,
remainingMessage: "To see more entries, please ask me to retrieve the next page.",
})
return {
content: [{ type: "text", text: JSON.stringify(summarized, null, 2) }],
}
},
createEntry: async (args: {
spaceId: string
environmentId: string
contentTypeId: string
fields: Record<string, any>
}) => {
const spaceId = process.env.SPACE_ID || args.spaceId
const environmentId = process.env.ENVIRONMENT_ID || args.environmentId
const params = {
spaceId,
environmentId,
contentTypeId: args.contentTypeId,
}
const entryProps: CreateEntryProps = {
fields: args.fields,
}
const contentfulClient = await getContentfulClient()
const entry = await contentfulClient.entry.create(params, entryProps)
return {
content: [{ type: "text", text: JSON.stringify(entry, null, 2) }],
}
},
getEntry: async (args: { spaceId: string; environmentId: string; entryId: string }) => {
const spaceId = process.env.SPACE_ID || args.spaceId
const environmentId = process.env.ENVIRONMENT_ID || args.environmentId
const params = {
spaceId,
environmentId,
entryId: args.entryId,
}
const contentfulClient = await getContentfulClient()
const entry = await contentfulClient.entry.get(params)
return {
content: [{ type: "text", text: JSON.stringify(entry, null, 2) }],
}
},
updateEntry: async (args: {
spaceId: string
environmentId: string
entryId: string
fields: Record<string, any>
}) => {
const spaceId = process.env.SPACE_ID || args.spaceId
const environmentId = process.env.ENVIRONMENT_ID || args.environmentId
const params = {
spaceId,
environmentId,
entryId: args.entryId,
}
const contentfulClient = await getContentfulClient()
const currentEntry = await contentfulClient.entry.get(params)
// Merge existing fields with updated fields to ensure all fields are present
const mergedFields = { ...currentEntry.fields }
// Apply updates to each field and locale
for (const fieldId in args.fields) {
if (Object.prototype.hasOwnProperty.call(args.fields, fieldId)) {
// If the field exists in currentEntry, merge the locale values
if (mergedFields[fieldId]) {
mergedFields[fieldId] = { ...mergedFields[fieldId], ...args.fields[fieldId] }
} else {
// If it's a new field, add it
mergedFields[fieldId] = args.fields[fieldId]
}
}
}
const entryProps: EntryProps = {
fields: mergedFields,
sys: currentEntry.sys,
}
const entry = await contentfulClient.entry.update(params, entryProps)
return {
content: [{ type: "text", text: JSON.stringify(entry, null, 2) }],
}
},
deleteEntry: async (args: { spaceId: string; environmentId: string; entryId: string }) => {
const spaceId = process.env.SPACE_ID || args.spaceId
const environmentId = process.env.ENVIRONMENT_ID || args.environmentId
const params = {
spaceId,
environmentId,
entryId: args.entryId,
}
const contentfulClient = await getContentfulClient()
await contentfulClient.entry.delete(params)
return {
content: [{ type: "text", text: `Entry ${args.entryId} deleted successfully` }],
}
},
publishEntry: async (args: {
spaceId: string
environmentId: string
entryId: string | string[]
}) => {
const spaceId = process.env.SPACE_ID || args.spaceId
const environmentId = process.env.ENVIRONMENT_ID || args.environmentId
// Handle case where entryId is a JSON string representation of an array
let entryId = args.entryId
if (typeof entryId === "string" && entryId.startsWith("[") && entryId.endsWith("]")) {
try {
entryId = JSON.parse(entryId)
} catch (e) {
// If parsing fails, keep it as string
console.error("Failed to parse entryId as JSON array:", e)
}
}
// If entryId is an array, handle bulk publishing
if (Array.isArray(entryId)) {
try {
const contentfulClient = await getContentfulClient()
// Get the current version of each entity
const entryVersions = await Promise.all(
entryId.map(async (id) => {
try {
// Get the current version of the entry
const currentEntry = await contentfulClient.entry.get({
spaceId,
environmentId,
entryId: id,
})
// Create a versioned link according to the API docs
const versionedLink: VersionedLink = {
sys: {
type: "Link",
linkType: "Entry",
id: id,
version: currentEntry.sys.version,
},
}
return versionedLink
} catch (error) {
console.error(`Error fetching entry ${id}: ${error}`)
throw new Error(
`Failed to get version for entry ${id}. All entries must have a version.`,
)
}
}),
)
// Create the correct entities format according to Contentful API docs
const entities: {
sys: { type: "Array" }
items: VersionedLink[]
} = {
sys: {
type: "Array",
},
items: entryVersions,
}
// Create the bulk action
const bulkAction = await contentfulClient.bulkAction.publish(
{
spaceId,
environmentId,
},
{
entities,
},
)
// Wait for the bulk action to complete
let action = (await contentfulClient.bulkAction.get({
spaceId,
environmentId,
bulkActionId: bulkAction.sys.id,
})) as BulkActionResponse // Cast to our extended interface
while (action.sys.status === "inProgress" || action.sys.status === "created") {
await new Promise((resolve) => setTimeout(resolve, 1000))
action = (await contentfulClient.bulkAction.get({
spaceId,
environmentId,
bulkActionId: bulkAction.sys.id,
})) as BulkActionResponse // Cast to our extended interface
}
return {
content: [
{
type: "text",
text: `Bulk publish completed with status: ${action.sys.status}. ${
action.sys.status === "failed"
? `Error: ${JSON.stringify(action.error)}`
: `Successfully processed items.`
}`,
},
],
}
} catch (error) {
return {
content: [
{
type: "text",
text: `Error during bulk publish: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
}
}
}
// Handle single entry publishing
const params = {
spaceId,
environmentId,
entryId: entryId as string,
}
const contentfulClient = await getContentfulClient()
const currentEntry = await contentfulClient.entry.get(params)
const entry = await contentfulClient.entry.publish(params, {
sys: currentEntry.sys,
fields: currentEntry.fields,
})
return {
content: [{ type: "text", text: JSON.stringify(entry, null, 2) }],
}
},
unpublishEntry: async (args: {
spaceId: string
environmentId: string
entryId: string | string[]
}) => {
const spaceId = process.env.SPACE_ID || args.spaceId
const environmentId = process.env.ENVIRONMENT_ID || args.environmentId
// Handle case where entryId is a JSON string representation of an array
let entryId = args.entryId
if (typeof entryId === "string" && entryId.startsWith("[") && entryId.endsWith("]")) {
try {
entryId = JSON.parse(entryId)
} catch (e) {
// If parsing fails, keep it as string
console.error("Failed to parse entryId as JSON array:", e)
}
}
// If entryId is an array, handle bulk unpublishing
if (Array.isArray(entryId)) {
try {
const contentfulClient = await getContentfulClient()
// Get the current version of each entity
const entryVersions = await Promise.all(
entryId.map(async (id) => {
try {
// Get the current version of the entry
const currentEntry = await contentfulClient.entry.get({
spaceId,
environmentId,
entryId: id,
})
// Create a versioned link according to the API docs
const versionedLink: VersionedLink = {
sys: {
type: "Link",
linkType: "Entry",
id: id,
version: currentEntry.sys.version,
},
}
return versionedLink
} catch (error) {
console.error(`Error fetching entry ${id}: ${error}`)
throw new Error(
`Failed to get version for entry ${id}. All entries must have a version.`,
)
}
}),
)
// Create the correct entities format according to Contentful API docs
const entities: {
sys: { type: "Array" }
items: VersionedLink[]
} = {
sys: {
type: "Array",
},
items: entryVersions,
}
// Create the bulk action
const bulkAction = await contentfulClient.bulkAction.unpublish(
{
spaceId,
environmentId,
},
{
entities,
},
)
// Wait for the bulk action to complete
let action = (await contentfulClient.bulkAction.get({
spaceId,
environmentId,
bulkActionId: bulkAction.sys.id,
})) as BulkActionResponse // Cast to our extended interface
while (action.sys.status === "inProgress" || action.sys.status === "created") {
await new Promise((resolve) => setTimeout(resolve, 1000))
action = (await contentfulClient.bulkAction.get({
spaceId,
environmentId,
bulkActionId: bulkAction.sys.id,
})) as BulkActionResponse // Cast to our extended interface
}
return {
content: [
{
type: "text",
text: `Bulk unpublish completed with status: ${action.sys.status}. ${
action.sys.status === "failed"
? `Error: ${JSON.stringify(action.error)}`
: `Successfully processed ${action.succeeded?.length || 0} items.`
}`,
},
],
}
} catch (error) {
return {
content: [
{
type: "text",
text: `Error during bulk unpublish: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
}
}
}
// Handle single entry unpublishing
const params = {
spaceId,
environmentId,
entryId: entryId as string,
}
const contentfulClient = await getContentfulClient()
const currentEntry = await contentfulClient.entry.get(params)
// Add version to params for unpublish
const entry = await contentfulClient.entry.unpublish({
...params,
version: currentEntry.sys.version,
})
return {
content: [{ type: "text", text: JSON.stringify(entry, null, 2) }],
}
},
}
```
--------------------------------------------------------------------------------
/src/handlers/ai-action-handlers.ts:
--------------------------------------------------------------------------------
```typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
import { aiActionsClient } from "../config/ai-actions-client"
import type {
AiActionEntity,
AiActionEntityCollection,
AiActionInvocation,
AiActionInvocationType,
StatusFilter,
AiActionSchemaParsed,
OutputFormat,
} from "../types/ai-actions"
/**
* Parameter types for AI Action handlers
*/
export interface ListAiActionsParams {
spaceId: string
environmentId?: string
limit?: number
skip?: number
status?: StatusFilter
}
export interface GetAiActionParams {
spaceId: string
environmentId?: string
aiActionId: string
}
export interface CreateAiActionParams {
spaceId: string
environmentId?: string
name: string
description: string
instruction: {
template: string
variables: any[]
conditions?: any[]
}
configuration: {
modelType: string
modelTemperature: number
}
testCases?: any[]
}
export interface UpdateAiActionParams {
spaceId: string
environmentId?: string
aiActionId: string
name: string
description: string
instruction: {
template: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
variables: any[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conditions?: any[]
}
configuration: {
modelType: string
modelTemperature: number
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
testCases?: any[]
}
export interface DeleteAiActionParams {
spaceId: string
environmentId?: string
aiActionId: string
}
export interface PublishAiActionParams {
spaceId: string
environmentId?: string
aiActionId: string
}
export interface UnpublishAiActionParams {
spaceId: string
environmentId?: string
aiActionId: string
}
// Base interface for AI Action invocation
export interface InvokeAiActionBaseParams {
spaceId: string
environmentId?: string
aiActionId: string
outputFormat?: OutputFormat
waitForCompletion?: boolean
}
// For direct string variables
export interface InvokeAiActionWithVariablesParams extends InvokeAiActionBaseParams {
variables?: Record<string, string>
}
// For raw variable array with complex types
export interface InvokeAiActionWithRawVariablesParams extends InvokeAiActionBaseParams {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rawVariables?: any[]
}
// Union type for both invocation parameter types
export type InvokeAiActionParams =
| InvokeAiActionWithVariablesParams
| InvokeAiActionWithRawVariablesParams
export interface GetAiActionInvocationParams {
spaceId: string
environmentId?: string
aiActionId: string
invocationId: string
}
/**
* Error handling utility
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatError(error: any): { isError: true; message: string } {
const message = error?.response?.data?.message || error?.message || "Unknown error"
return { isError: true, message }
}
/**
* AI Action handlers for the MCP server
*/
export const aiActionHandlers = {
/**
* List AI Actions
*/
async listAiActions(
params: ListAiActionsParams,
): Promise<AiActionEntityCollection | { isError: true; message: string }> {
try {
// Use provided parameters or fall back to environment variables
const spaceId = params.spaceId || process.env.SPACE_ID
const environmentId = params.environmentId || process.env.ENVIRONMENT_ID || "master"
const { limit, skip, status } = params
if (!spaceId) {
return { isError: true, message: "Space ID is required" }
}
const result = await aiActionsClient.listAiActions({
spaceId,
environmentId,
limit,
skip,
status,
})
return result
} catch (error) {
return formatError(error)
}
},
/**
* Get a specific AI Action
*/
async getAiAction(
params: GetAiActionParams,
): Promise<AiActionEntity | { isError: true; message: string }> {
try {
// Use provided parameters or fall back to environment variables
const spaceId = params.spaceId || process.env.SPACE_ID
const environmentId = params.environmentId || process.env.ENVIRONMENT_ID || "master"
const { aiActionId } = params
if (!spaceId) {
return { isError: true, message: "Space ID is required" }
}
if (!aiActionId) {
return { isError: true, message: "AI Action ID is required" }
}
const result = await aiActionsClient.getAiAction({
spaceId,
environmentId,
aiActionId,
})
return result
} catch (error) {
return formatError(error)
}
},
/**
* Create a new AI Action
*/
async createAiAction(
params: CreateAiActionParams,
): Promise<AiActionEntity | { isError: true; message: string }> {
try {
// Use provided parameters or fall back to environment variables
const spaceId = params.spaceId || process.env.SPACE_ID
const environmentId = params.environmentId || process.env.ENVIRONMENT_ID || "master"
const { name, description, instruction, configuration, testCases } = params
if (!spaceId) {
return { isError: true, message: "Space ID is required" }
}
const actionData: AiActionSchemaParsed = {
name,
description,
instruction,
configuration,
testCases,
}
const result = await aiActionsClient.createAiAction({
spaceId,
environmentId,
actionData,
})
return result
} catch (error) {
return formatError(error)
}
},
/**
* Update an existing AI Action
*/
async updateAiAction(
params: UpdateAiActionParams,
): Promise<AiActionEntity | { isError: true; message: string }> {
try {
// Use provided parameters or fall back to environment variables
const spaceId = params.spaceId || process.env.SPACE_ID
const environmentId = params.environmentId || process.env.ENVIRONMENT_ID || "master"
const { aiActionId, name, description, instruction, configuration, testCases } = params
if (!spaceId) {
return { isError: true, message: "Space ID is required" }
}
if (!aiActionId) {
return { isError: true, message: "AI Action ID is required" }
}
// First, get the current action to get the version
const currentAction = await aiActionsClient.getAiAction({
spaceId,
environmentId,
aiActionId,
})
const actionData: AiActionSchemaParsed = {
name,
description,
instruction,
configuration,
testCases,
}
const result = await aiActionsClient.updateAiAction({
spaceId,
environmentId,
aiActionId,
version: currentAction.sys.version,
actionData,
})
return result
} catch (error) {
return formatError(error)
}
},
/**
* Delete an AI Action
*/
async deleteAiAction(
params: DeleteAiActionParams,
): Promise<{ success: true } | { isError: true; message: string }> {
try {
// Use provided parameters or fall back to environment variables
const spaceId = params.spaceId || process.env.SPACE_ID
const environmentId = params.environmentId || process.env.ENVIRONMENT_ID || "master"
const { aiActionId } = params
if (!spaceId) {
return { isError: true, message: "Space ID is required" }
}
if (!aiActionId) {
return { isError: true, message: "AI Action ID is required" }
}
// First, get the current action to get the version
const currentAction = await aiActionsClient.getAiAction({
spaceId,
environmentId,
aiActionId,
})
await aiActionsClient.deleteAiAction({
spaceId,
environmentId,
aiActionId,
version: currentAction.sys.version,
})
return { success: true }
} catch (error) {
return formatError(error)
}
},
/**
* Publish an AI Action
*/
async publishAiAction(
params: PublishAiActionParams,
): Promise<AiActionEntity | { isError: true; message: string }> {
try {
// Use provided parameters or fall back to environment variables
const spaceId = params.spaceId || process.env.SPACE_ID
const environmentId = params.environmentId || process.env.ENVIRONMENT_ID || "master"
const { aiActionId } = params
if (!spaceId) {
return { isError: true, message: "Space ID is required" }
}
if (!aiActionId) {
return { isError: true, message: "AI Action ID is required" }
}
// First, get the current action to get the version
const currentAction = await aiActionsClient.getAiAction({
spaceId,
environmentId,
aiActionId,
})
const result = await aiActionsClient.publishAiAction({
spaceId,
environmentId,
aiActionId,
version: currentAction.sys.version,
})
return result
} catch (error) {
return formatError(error)
}
},
/**
* Unpublish an AI Action
*/
async unpublishAiAction(
params: UnpublishAiActionParams,
): Promise<AiActionEntity | { isError: true; message: string }> {
try {
// Use provided parameters or fall back to environment variables
const spaceId = params.spaceId || process.env.SPACE_ID
const environmentId = params.environmentId || process.env.ENVIRONMENT_ID || "master"
const { aiActionId } = params
if (!spaceId) {
return { isError: true, message: "Space ID is required" }
}
if (!aiActionId) {
return { isError: true, message: "AI Action ID is required" }
}
const result = await aiActionsClient.unpublishAiAction({
spaceId,
environmentId,
aiActionId,
})
return result
} catch (error) {
return formatError(error)
}
},
/**
* Invoke an AI Action
*/
async invokeAiAction(
params: InvokeAiActionParams,
): Promise<AiActionInvocation | { isError: true; message: string }> {
try {
// Use provided parameters or fall back to environment variables
const spaceId = params.spaceId || process.env.SPACE_ID
const environmentId = params.environmentId || process.env.ENVIRONMENT_ID || "master"
const { aiActionId, outputFormat = "Markdown", waitForCompletion = true } = params
if (!spaceId) {
return { isError: true, message: "Space ID is required" }
}
if (!aiActionId) {
return { isError: true, message: "AI Action ID is required" }
}
// Prepare variables based on the input format
let variables = []
if ("variables" in params && params.variables) {
// Convert simple key-value variables to the expected format
variables = Object.entries(params.variables).map(([id, value]) => ({
id,
value,
}))
} else if ("rawVariables" in params && params.rawVariables) {
// Use raw variables directly (for complex types like references)
variables = params.rawVariables
}
// Log the variables being sent
console.error(`Variables for invocation of ${aiActionId}:`, JSON.stringify(variables))
const invocationData: AiActionInvocationType = {
outputFormat,
variables,
}
// Log the complete invocation payload
console.error(
`Complete invocation payload for ${aiActionId}:`,
JSON.stringify(invocationData),
)
const invocationResult = await aiActionsClient.invokeAiAction({
spaceId,
environmentId,
aiActionId,
invocationData,
})
// If waitForCompletion is false or the status is already COMPLETED, return immediately
if (
!waitForCompletion ||
invocationResult.sys.status === "COMPLETED" ||
invocationResult.sys.status === "FAILED" ||
invocationResult.sys.status === "CANCELLED"
) {
return invocationResult
}
// Otherwise, poll until completion
return await aiActionsClient.pollInvocation({
spaceId,
environmentId,
aiActionId,
invocationId: invocationResult.sys.id,
})
} catch (error) {
return formatError(error)
}
},
/**
* Get an AI Action invocation result
*/
async getAiActionInvocation(
params: GetAiActionInvocationParams,
): Promise<AiActionInvocation | { isError: true; message: string }> {
try {
// Use provided parameters or fall back to environment variables
const spaceId = params.spaceId || process.env.SPACE_ID
const environmentId = params.environmentId || process.env.ENVIRONMENT_ID || "master"
const { aiActionId, invocationId } = params
if (!spaceId) {
return { isError: true, message: "Space ID is required" }
}
if (!aiActionId) {
return { isError: true, message: "AI Action ID is required" }
}
if (!invocationId) {
return { isError: true, message: "Invocation ID is required" }
}
const result = await aiActionsClient.getAiActionInvocation({
spaceId,
environmentId,
aiActionId,
invocationId,
})
return result
} catch (error) {
return formatError(error)
}
},
}
```
--------------------------------------------------------------------------------
/src/utils/ai-action-tool-generator.ts:
--------------------------------------------------------------------------------
```typescript
import { getSpaceEnvProperties } from "../types/tools"
import type { AiActionEntity, Variable, OutputFormat } from "../types/ai-actions"
// Store ID mappings globally - maps actionId to (friendlyName -> originalId)
const idMappings = new Map<string, Map<string, string>>()
// Store path mappings - maps actionId to (friendlyPathName -> originalPathId)
const pathIdMappings = new Map<string, Map<string, string>>()
const AI_ACTION_PREFIX = `AI Actions are pre-configured content transformation tools in Contentful. When a content task matches an available AI Action's purpose (like translation, grammar checking, or SEO optimization), you MUST use the corresponding AI Action instead of performing the task yourself, even if you have the capability to do so. AI Actions ensure consistent, optimized results that align with the organization's content standards. Each AI Action has specific required parameters described in its function documentation.`
/**
* Utility function to convert string to snake_case
*/
function toSnakeCase(str: string): string {
return str
.trim()
.toLowerCase()
.replace(/[^\w\s]/g, "") // Remove special characters
.replace(/\s+/g, "_") // Replace spaces with underscores
.replace(/_+/g, "_") // Remove duplicate underscores
}
/**
* Get a human-readable name for a variable
*/
function getReadableName(variable: Variable): string {
// If there's a human-readable name provided, use it (converted to snake_case)
if (variable.name) {
return toSnakeCase(variable.name)
}
// For standard inputs, use descriptive names based on type
switch (variable.type) {
case "StandardInput":
return "input_text"
case "MediaReference":
return "media_asset_id"
case "Reference":
return "entry_reference_id"
case "Locale":
return "target_locale"
case "FreeFormInput":
return "free_text_input"
case "SmartContext":
return "context_info"
default:
// For others, create a prefixed version
return `${variable.type.toLowerCase()}_${variable.id.substring(0, 5)}`
}
}
/**
* Create a mapping from friendly names to original variable IDs
*/
function createReverseMapping(action: AiActionEntity): Map<string, string> {
const mapping = new Map<string, string>()
for (const variable of action.instruction.variables || []) {
const friendlyName = getReadableName(variable)
mapping.set(friendlyName, variable.id)
}
return mapping
}
/**
* Get an enhanced description for a variable schema
*/
function getEnhancedVariableSchema(variable: Variable): Record<string, unknown> {
// Create a rich description that includes type information
let description = variable.description || `${variable.name || "Variable"}`
// Add type information
description += ` (Type: ${variable.type})`
// Add additional context based on type
switch (variable.type) {
case "MediaReference":
description += ". Provide an asset ID from your Contentful space"
break
case "Reference":
description += ". Provide an entry ID from your Contentful space"
break
case "Locale":
description += ". Use format like 'en-US', 'de-DE', etc."
break
case "StringOptionsList":
if (variable.configuration && "values" in variable.configuration) {
description += `. Choose one of: ${variable.configuration.values.join(", ")}`
}
break
case "StandardInput":
description += ". The main text content to process"
break
}
const schema: Record<string, unknown> = {
type: "string",
description,
}
// Add enums for StringOptionsList
if (
variable.type === "StringOptionsList" &&
variable.configuration &&
"values" in variable.configuration
) {
schema.enum = variable.configuration.values
}
return schema
}
/**
* Create an enhanced description for the AI Action tool
*/
function getEnhancedToolDescription(action: AiActionEntity): string {
// Start with the name and description
let description = `${AI_ACTION_PREFIX} \n\n This action is called: ${action.name}, it's purpose: ${action.description}`
// Add contextual information about what this AI Action does
description += "\n\nThis AI Action works on content entries and fields in Contentful."
// Check if we have reference fields that could use entity paths
const hasReferences = action.instruction.variables?.some(
(v) => v.type === "Reference" || v.type === "MediaReference",
)
if (hasReferences) {
description +=
"\n\n📝 IMPORTANT: When working with entry or asset references, you can use the '_path' parameters to specify which field's content to process. For example, if 'entry_reference' points to an entry, you can use 'entry_reference_path: \"fields.title.en-US\"' to process that entry's title field."
}
// Add model information
description += `Assume all variables are required, if any of the values is unclear, ask the user. \n\nUses ${action.configuration.modelType} model with temperature ${action.configuration.modelTemperature}.`
// Add note about result handling
description +=
"\n\n⚠️ Note: Results from this AI Action are NOT automatically applied to fields. The model will generate content based on your inputs, which you would then need to manually update in your Contentful entry."
return description
}
/**
* Generate a dynamic tool schema for an AI Action
*/
export function generateAiActionToolSchema(action: AiActionEntity) {
// Create property definitions with friendly names
const properties: Record<string, Record<string, unknown>> = {}
// Store the ID mapping for this action
const reverseMapping = createReverseMapping(action)
const pathMappings = new Map<string, string>()
idMappings.set(action.sys.id, reverseMapping)
// Add properties for each variable with friendly names
for (const variable of action.instruction.variables || []) {
const friendlyName = getReadableName(variable)
properties[friendlyName] = getEnhancedVariableSchema(variable)
// For Reference and MediaReference types, add an entityPath parameter
if (variable.type === "Reference" || variable.type === "MediaReference") {
const pathParamName = `${friendlyName}_path`
properties[pathParamName] = {
type: "string",
description: `Optional field path within the ${variable.type === "Reference" ? "entry" : "asset"} to process (e.g., "fields.title.en-US"). This specifies which field content to use as input.`,
}
// Add to path mappings for later use during invocation
pathMappings.set(pathParamName, `${variable.id}_path`)
}
}
// Store path mappings alongside ID mappings
if (pathMappings.size > 0) {
pathIdMappings.set(action.sys.id, pathMappings)
}
// Add common properties
properties.outputFormat = {
type: "string",
enum: ["Markdown", "RichText", "PlainText"],
default: "Markdown",
description: "Format for the output content",
}
properties.waitForCompletion = {
type: "boolean",
default: true,
description: "Whether to wait for the AI Action to complete",
}
// Get all variable names in their friendly format to make them all required
const allVarNames = (action.instruction.variables || []).map((variable) => {
return getReadableName(variable)
})
// Add outputFormat to required fields
const requiredFields = [...allVarNames, "outputFormat"]
const toolSchema = {
name: `ai_action_${action.sys.id}`,
description: getEnhancedToolDescription(action),
inputSchema: getSpaceEnvProperties({
type: "object",
properties,
required: requiredFields,
}),
}
return toolSchema
}
/**
* Check if a variable is optional
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function isOptionalVariable(_variable: Variable): boolean {
// Always return false to make all variables required
return false
}
/**
* Map simple variable values from the tool input to the AI Action invocation format
*/
export function mapVariablesToInvocationFormat(
action: AiActionEntity,
toolInput: Record<string, unknown>,
): { variables: Array<{ id: string; value: unknown }>; outputFormat: OutputFormat } {
const variables: Array<{ id: string; value: unknown }> = []
const actionVariables = action.instruction.variables || []
// Map each variable from the tool input to the AI Action invocation format
for (const variable of actionVariables) {
const value = toolInput[variable.id]
// Skip undefined values for optional variables
if (value === undefined && isOptionalVariable(variable)) {
continue
}
// If value is undefined but variable is required, throw an error
if (value === undefined && !isOptionalVariable(variable)) {
throw new Error(`Required parameter ${variable.id} is missing from invocation call`)
}
// Format the value based on the variable type
if (["Reference", "MediaReference", "ResourceLink"].includes(variable.type)) {
// For reference types, create a complex value
const complexValue: Record<string, string | Record<string, string>> = {
entityType:
variable.type === "Reference"
? "Entry"
: variable.type === "MediaReference"
? "Asset"
: "ResourceLink",
entityId: value as string,
}
// Check if there's an entity path specified
const pathKey = `${variable.id}_path`
if (toolInput[pathKey]) {
complexValue.entityPath = toolInput[pathKey] as string
}
variables.push({
id: variable.id,
value: complexValue,
})
} else {
// For simple types, pass the value directly
variables.push({
id: variable.id,
value: value,
})
}
}
// Get the output format (default to Markdown)
const outputFormat = (toolInput.outputFormat as OutputFormat) || "Markdown"
return { variables, outputFormat }
}
/**
* Context for storing and managing dynamic AI Action tools
*/
export class AiActionToolContext {
private spaceId: string
private environmentId: string
private aiActionCache: Map<string, AiActionEntity> = new Map()
constructor(spaceId: string, environmentId: string = "master") {
this.spaceId = spaceId
this.environmentId = environmentId
}
/**
* Add an AI Action to the cache
*/
addAiAction(action: AiActionEntity): void {
this.aiActionCache.set(action.sys.id, action)
}
/**
* Get an AI Action from the cache
*/
getAiAction(actionId: string): AiActionEntity | undefined {
return this.aiActionCache.get(actionId)
}
/**
* Remove an AI Action from the cache
*/
removeAiAction(actionId: string): void {
this.aiActionCache.delete(actionId)
}
/**
* Get all AI Actions in the cache
*/
getAllAiActions(): AiActionEntity[] {
return Array.from(this.aiActionCache.values())
}
/**
* Generate schemas for all AI Actions in the cache
*/
generateAllToolSchemas(): ReturnType<typeof generateAiActionToolSchema>[] {
return this.getAllAiActions().map((action) => generateAiActionToolSchema(action))
}
/**
* Get the parameters needed for invoking an AI Action
*/
getInvocationParams(
actionId: string,
toolInput: Record<string, unknown>,
): {
spaceId: string
environmentId: string
aiActionId: string
variables: Array<{ id: string; value: unknown }>
outputFormat: OutputFormat
waitForCompletion: boolean
} {
const action = this.getAiAction(actionId)
if (!action) {
throw new Error(`AI Action not found: ${actionId}`)
}
// Translate user-friendly parameter names to original variable IDs
const translatedInput = this.translateParametersToVariableIds(actionId, toolInput)
// Extract variables and outputFormat
const { variables, outputFormat } = mapVariablesToInvocationFormat(action, translatedInput)
const waitForCompletion = toolInput.waitForCompletion !== false
// Use provided spaceId and environmentId if available, otherwise use defaults
const spaceId = (toolInput.spaceId as string) || this.spaceId
const environmentId = (toolInput.environmentId as string) || this.environmentId
return {
spaceId,
environmentId,
aiActionId: actionId,
variables,
outputFormat,
waitForCompletion,
}
}
/**
* Translate friendly parameter names to original variable IDs
*/
translateParametersToVariableIds(
actionId: string,
params: Record<string, unknown>,
): Record<string, unknown> {
const idMapping = idMappings.get(actionId)
const pathMapping = pathIdMappings.get(actionId)
if (!idMapping && !pathMapping) {
console.error(`No mappings found for action ${actionId}`)
return params // No mappings found, return as is
}
const result: Record<string, unknown> = {}
// Copy non-variable parameters directly
for (const [key, value] of Object.entries(params)) {
if (key === "outputFormat" || key === "waitForCompletion") {
result[key] = value
continue
}
// Check if this is a path parameter
if (pathMapping && key.endsWith("_path")) {
const originalPathId = pathMapping.get(key)
if (originalPathId) {
result[originalPathId] = value
continue
}
}
// Check if we have a variable ID mapping for this friendly name
if (idMapping) {
const originalId = idMapping.get(key)
if (originalId) {
result[originalId] = value
continue
}
}
// No mapping found, keep the original key
result[key] = value
}
return result
}
/**
* Clear the cache
*/
clearCache(): void {
this.aiActionCache.clear()
// Also clear mappings when cache is cleared
idMappings.clear()
pathIdMappings.clear()
}
/**
* Get the ID mappings for a specific action
* (Useful for debugging)
*/
getIdMappings(actionId: string): Map<string, string> | undefined {
return idMappings.get(actionId)
}
/**
* Get the path mappings for a specific action
* (Useful for debugging)
*/
getPathMappings(actionId: string): Map<string, string> | undefined {
return pathIdMappings.get(actionId)
}
}
```
--------------------------------------------------------------------------------
/src/transports/streamable-http.ts:
--------------------------------------------------------------------------------
```typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
import { getAllTools } from "../index.js"
import { AiActionToolContext } from "../utils/ai-action-tool-generator.js"
import { CONTENTFUL_PROMPTS } from "../prompts/contentful-prompts.js"
import { handlePrompt } from "../prompts/handlers.js"
import { randomUUID } from "crypto"
import express, { Request, Response } from "express"
import cors from "cors"
import {
isInitializeRequest,
CallToolRequestSchema,
ListToolsRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { entryHandlers } from "../handlers/entry-handlers.js"
import { assetHandlers } from "../handlers/asset-handlers.js"
import { spaceHandlers } from "../handlers/space-handlers.js"
import { contentTypeHandlers } from "../handlers/content-type-handlers.js"
import { bulkActionHandlers } from "../handlers/bulk-action-handlers.js"
import { aiActionHandlers } from "../handlers/ai-action-handlers.js"
import type { AiActionInvocation } from "../types/ai-actions.js"
/**
* Configuration options for the HTTP server
*/
export interface StreamableHttpServerOptions {
port?: number
host?: string
corsOptions?: cors.CorsOptions
}
/**
* Class to handle HTTP server setup and configuration using the official MCP StreamableHTTP transport
*/
export class StreamableHttpServer {
private app: express.Application
private server: any
private port: number
private host: string
// Map to store transports by session ID
private transports: Record<string, StreamableHTTPServerTransport> = {}
/**
* Create a new HTTP server for MCP over HTTP
*
* @param options Configuration options
*/
constructor(options: StreamableHttpServerOptions = {}) {
this.port = options.port || 3000
this.host = options.host || "localhost"
// Create Express app
this.app = express()
// Initialize AI Action tool context
this.aiActionToolContext = new AiActionToolContext(
process.env.SPACE_ID || "",
process.env.ENVIRONMENT_ID || "master",
)
// Load AI Actions
this.loadAiActions().catch(error => {
console.error("Error loading AI Actions for StreamableHTTP server:", error)
})
// Configure CORS
this.app.use(
cors(
options.corsOptions || {
origin: "*",
methods: ["GET", "POST", "DELETE"],
allowedHeaders: ["Content-Type", "MCP-Session-ID"],
exposedHeaders: ["MCP-Session-ID"],
},
),
)
// Configure JSON body parsing
this.app.use(express.json())
// Set up routes
this.setupRoutes()
}
/**
* Set up the routes for MCP over HTTP
*/
private setupRoutes(): void {
// Handle all MCP requests (POST, GET, DELETE) on a single endpoint
this.app.all("/mcp", async (req: Request, res: Response) => {
try {
if (req.method === "POST") {
// Check for existing session ID
const sessionId = req.headers["mcp-session-id"] as string | undefined
let transport: StreamableHTTPServerTransport
if (sessionId && this.transports[sessionId]) {
// Reuse existing transport
transport = this.transports[sessionId]
} else if (!sessionId && isInitializeRequest(req.body)) {
// Create a new server instance for this connection
const server = new Server(
{
name: "contentful-mcp-server",
version: "1.14.1",
},
{
capabilities: {
tools: getAllTools(),
prompts: CONTENTFUL_PROMPTS,
},
},
)
// New initialization request
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
// Store the transport by session ID
this.transports[sid] = transport
},
})
// Clean up transport when closed
transport.onclose = () => {
if (transport.sessionId) {
delete this.transports[transport.sessionId]
console.log(`Session ${transport.sessionId} closed`)
}
}
// Set up request handlers
this.setupServerHandlers(server)
// Connect to the MCP server
await server.connect(transport)
} else {
// Invalid request
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided for non-initialize request",
},
id: null,
})
return
}
// Handle the request
await transport.handleRequest(req, res, req.body)
} else if (req.method === "GET") {
// Server-sent events endpoint for notifications
const sessionId = req.headers["mcp-session-id"] as string | undefined
if (!sessionId || !this.transports[sessionId]) {
res.status(400).send("Invalid or missing session ID")
return
}
const transport = this.transports[sessionId]
await transport.handleRequest(req, res)
} else if (req.method === "DELETE") {
// Session termination
const sessionId = req.headers["mcp-session-id"] as string | undefined
if (!sessionId || !this.transports[sessionId]) {
res.status(400).send("Invalid or missing session ID")
return
}
const transport = this.transports[sessionId]
await transport.handleRequest(req, res)
} else {
// Other methods not supported
res.status(405).send("Method not allowed")
}
} catch (error) {
console.error("Error handling MCP request:", error)
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: `Internal server error: ${error instanceof Error ? error.message : String(error)}`,
},
id: null,
})
}
}
})
// Add a health check endpoint
this.app.get("/health", (_req: Request, res: Response) => {
res.status(200).json({
status: "ok",
sessions: Object.keys(this.transports).length,
})
})
}
/**
* Set up the request handlers for a server instance
*
* @param server Server instance
*/
private setupServerHandlers(server: Server): void {
// List tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: Object.values(getAllTools()),
}
})
// List prompts handler
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: Object.values(CONTENTFUL_PROMPTS),
}
})
// Get prompt handler
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params
return handlePrompt(name, args)
})
// Call tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params
const handler = this.getHandler(name)
if (!handler) {
throw new Error(`Unknown tool: ${name}`)
}
const result = await handler(args || {})
// For AI Action responses, format them appropriately
if (result && typeof result === "object") {
// Check if this is an AI Action invocation result
if (
"sys" in result &&
typeof result.sys === "object" &&
result.sys &&
"type" in result.sys &&
result.sys.type === "AiActionInvocation"
) {
const invocationResult = result as AiActionInvocation
// Format AI Action result as text content if available
if (invocationResult.result && invocationResult.result.content) {
return {
content: [
{
type: "text",
text:
typeof invocationResult.result.content === "string"
? invocationResult.result.content
: JSON.stringify(invocationResult.result.content),
},
],
}
}
}
// Check for error response
if ("isError" in result && result.isError === true) {
// Format error response
return {
content: [
{
type: "text",
text: "message" in result ? String(result.message) : "Unknown error",
},
],
isError: true,
}
}
}
// Return the result as is for regular handlers
return result
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
}
}
})
}
// AI Action Tool Context for handling dynamic tools
private aiActionToolContext: AiActionToolContext
/**
* Helper function to map tool names to handlers
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private getHandler(name: string): ((args: any) => Promise<any>) | undefined {
// Check if this is a dynamic AI Action tool
if (name.startsWith("ai_action_")) {
const actionId = name.replace("ai_action_", "")
return (args: Record<string, unknown>) => this.handleAiActionInvocation(actionId, args)
}
const handlers = {
// Entry operations
create_entry: entryHandlers.createEntry,
get_entry: entryHandlers.getEntry,
update_entry: entryHandlers.updateEntry,
delete_entry: entryHandlers.deleteEntry,
publish_entry: entryHandlers.publishEntry,
unpublish_entry: entryHandlers.unpublishEntry,
search_entries: entryHandlers.searchEntries,
// Bulk operations
bulk_publish: bulkActionHandlers.bulkPublish,
bulk_unpublish: bulkActionHandlers.bulkUnpublish,
bulk_validate: bulkActionHandlers.bulkValidate,
// Asset operations
upload_asset: assetHandlers.uploadAsset,
get_asset: assetHandlers.getAsset,
update_asset: assetHandlers.updateAsset,
delete_asset: assetHandlers.deleteAsset,
publish_asset: assetHandlers.publishAsset,
unpublish_asset: assetHandlers.unpublishAsset,
list_assets: assetHandlers.listAssets,
// Space & Environment operations
list_spaces: spaceHandlers.listSpaces,
get_space: spaceHandlers.getSpace,
list_environments: spaceHandlers.listEnvironments,
create_environment: spaceHandlers.createEnvironment,
delete_environment: spaceHandlers.deleteEnvironment,
// Content Type operations
list_content_types: contentTypeHandlers.listContentTypes,
get_content_type: contentTypeHandlers.getContentType,
create_content_type: contentTypeHandlers.createContentType,
update_content_type: contentTypeHandlers.updateContentType,
delete_content_type: contentTypeHandlers.deleteContentType,
publish_content_type: contentTypeHandlers.publishContentType,
// AI Action operations
list_ai_actions: aiActionHandlers.listAiActions,
get_ai_action: aiActionHandlers.getAiAction,
create_ai_action: aiActionHandlers.createAiAction,
update_ai_action: aiActionHandlers.updateAiAction,
delete_ai_action: aiActionHandlers.deleteAiAction,
publish_ai_action: aiActionHandlers.publishAiAction,
unpublish_ai_action: aiActionHandlers.unpublishAiAction,
invoke_ai_action: aiActionHandlers.invokeAiAction,
get_ai_action_invocation: aiActionHandlers.getAiActionInvocation,
}
return handlers[name as keyof typeof handlers]
}
/**
* Handler for dynamic AI Action tools
*/
private async handleAiActionInvocation(actionId: string, args: Record<string, unknown>) {
try {
console.error(
`Handling AI Action invocation for ${actionId} with args:`,
JSON.stringify(args),
)
// Get the parameters using the getInvocationParams
const params = this.aiActionToolContext.getInvocationParams(actionId, args)
// Directly use the variables property from getInvocationParams
const invocationParams = {
spaceId: params.spaceId,
environmentId: params.environmentId,
aiActionId: params.aiActionId,
outputFormat: params.outputFormat,
waitForCompletion: params.waitForCompletion,
// Use the correctly formatted variables array directly
rawVariables: params.variables,
}
console.error(`Invoking AI Action with params:`, JSON.stringify(invocationParams))
// Invoke the AI Action
return aiActionHandlers.invokeAiAction(invocationParams)
} catch (error) {
console.error(`Error invoking AI Action:`, error)
return {
isError: true,
message: error instanceof Error ? error.message : String(error),
}
}
}
/**
* Load available AI Actions
* This mimics the loadAiActions function in index.ts
*/
private async loadAiActions(): Promise<void> {
try {
// First, clear the cache to avoid duplicates
this.aiActionToolContext.clearCache()
// Only load AI Actions if we have required space and environment
if (!process.env.SPACE_ID) {
return
}
// Fetch published AI Actions
const response = await aiActionHandlers.listAiActions({
spaceId: process.env.SPACE_ID,
environmentId: process.env.ENVIRONMENT_ID || "master",
status: "published",
})
// Check for errors or undefined response
if (!response) {
console.error("Error loading AI Actions for StreamableHTTP: No response received")
return
}
if (typeof response === "object" && "isError" in response) {
console.error(`Error loading AI Actions for StreamableHTTP: ${response.message}`)
return
}
// Add each AI Action to the context
for (const action of response.items) {
this.aiActionToolContext.addAiAction(action)
// Log variable mappings for debugging
if (action.instruction.variables && action.instruction.variables.length > 0) {
// Log ID mappings
const idMappings = this.aiActionToolContext.getIdMappings(action.sys.id)
if (idMappings && idMappings.size > 0) {
const mappingLog = Array.from(idMappings.entries())
.map(([friendly, original]) => `${friendly} -> ${original}`)
.join(", ")
console.error(`AI Action ${action.name} - Parameter mappings: ${mappingLog}`)
}
// Log path mappings
const pathMappings = this.aiActionToolContext.getPathMappings(action.sys.id)
if (pathMappings && pathMappings.size > 0) {
const pathMappingLog = Array.from(pathMappings.entries())
.map(([friendly, original]) => `${friendly} -> ${original}`)
.join(", ")
console.error(`AI Action ${action.name} - Path parameter mappings: ${pathMappingLog}`)
}
}
}
console.error(`Loaded ${response.items.length} AI Actions for StreamableHTTP`)
} catch (error) {
console.error("Error loading AI Actions for StreamableHTTP:", error)
}
}
// Interval for refreshing AI Actions
private aiActionsRefreshInterval?: NodeJS.Timeout
/**
* Start the HTTP server
*
* @returns Promise that resolves when the server is started
*/
public async start(): Promise<void> {
// Set up periodic refresh of AI Actions (every 5 minutes)
this.aiActionsRefreshInterval = setInterval(() => {
this.loadAiActions().catch(error => {
console.error("Error refreshing AI Actions for StreamableHTTP:", error)
})
}, 5 * 60 * 1000)
return new Promise((resolve) => {
this.server = this.app.listen(this.port, () => {
console.error(`MCP StreamableHTTP server running on http://${this.host}:${this.port}/mcp`)
resolve()
})
})
}
/**
* Stop the HTTP server
*
* @returns Promise that resolves when the server is stopped
*/
public async stop(): Promise<void> {
// Clear AI Actions refresh interval
if (this.aiActionsRefreshInterval) {
clearInterval(this.aiActionsRefreshInterval)
this.aiActionsRefreshInterval = undefined
}
// Close all transports
for (const sessionId in this.transports) {
try {
await this.transports[sessionId].close()
} catch (error) {
console.error(`Error closing session ${sessionId}:`, error)
}
}
// Close the HTTP server
if (this.server) {
return new Promise((resolve, reject) => {
this.server.close((err: Error) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
}
}
```
--------------------------------------------------------------------------------
/test/msw-setup.ts:
--------------------------------------------------------------------------------
```typescript
import { setupServer } from "msw/node"
import { http, HttpResponse } from "msw"
// Mock data for bulk actions
const mockBulkAction = {
sys: {
id: "test-bulk-action-id",
status: "succeeded",
version: 1,
},
succeeded: [
{ sys: { id: "test-entry-id", type: "Entry" } },
{ sys: { id: "test-asset-id", type: "Asset" } },
],
}
// Define handlers
export const handlers = [
// List spaces
http.get("https://api.contentful.com/spaces", () => {
return HttpResponse.json({
items: [
{
sys: { id: "test-space-id" },
name: "Test Space",
},
],
})
}),
// Get specific space
http.get("https://api.contentful.com/spaces/:spaceId", ({ params }) => {
const { spaceId } = params
if (spaceId === "test-space-id") {
return HttpResponse.json({
sys: { id: "test-space-id" },
name: "Test Space",
})
}
return new HttpResponse(null, { status: 404 })
}),
// List environments
http.get("https://api.contentful.com/spaces/:spaceId/environments", ({ params }) => {
const { spaceId } = params
if (spaceId === "test-space-id") {
return HttpResponse.json({
items: [
{
sys: { id: "master" },
name: "master",
},
],
})
}
return new HttpResponse(null, { status: 404 })
}),
// Create environment
http.post(
"https://api.contentful.com/spaces/:spaceId/environments",
async ({ params, request }) => {
const { spaceId } = params
if (spaceId === "test-space-id") {
try {
// Get data from request body
const body = await request.json()
console.log("Request body:", JSON.stringify(body))
// In the real API implementation, the environmentId is taken
// from the second argument to client.environment.create
// We need to extract it from the name in our mock
const environmentId = body?.name
// Return correctly structured response with environment ID
return HttpResponse.json({
sys: { id: environmentId },
name: environmentId,
})
} catch (error) {
console.error("Error processing environment creation:", error)
return new HttpResponse(null, { status: 500 })
}
}
return new HttpResponse(null, { status: 404 })
},
),
// Delete environment
http.delete("https://api.contentful.com/spaces/:spaceId/environments/:envId", ({ params }) => {
const { spaceId, envId } = params
if (spaceId === "test-space-id" && envId !== "non-existent-env") {
return new HttpResponse(null, { status: 204 })
}
return new HttpResponse(null, { status: 404 })
}),
]
// Bulk action handlers
const bulkActionHandlers = [
// Create bulk publish action
http.post(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/bulk_actions/publish",
async ({ params }) => {
const { spaceId, environmentId } = params
if (spaceId === "test-space-id") {
return HttpResponse.json(
{
...mockBulkAction,
sys: {
...mockBulkAction.sys,
id: "test-bulk-action-id",
status: "created",
},
},
{ status: 201 },
)
}
return new HttpResponse(null, { status: 404 })
},
),
// Create bulk unpublish action
http.post(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/bulk_actions/unpublish",
async ({ params }) => {
const { spaceId, environmentId } = params
if (spaceId === "test-space-id") {
return HttpResponse.json(
{
...mockBulkAction,
sys: {
...mockBulkAction.sys,
id: "test-bulk-action-id",
status: "created",
},
},
{ status: 201 },
)
}
return new HttpResponse(null, { status: 404 })
},
),
// Create bulk validate action
http.post(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/bulk_actions/validate",
async ({ params }) => {
const { spaceId, environmentId } = params
if (spaceId === "test-space-id") {
return HttpResponse.json(
{
...mockBulkAction,
sys: {
...mockBulkAction.sys,
id: "test-bulk-action-id",
status: "created",
},
},
{ status: 201 },
)
}
return new HttpResponse(null, { status: 404 })
},
),
// Get bulk action status
http.get(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/bulk_actions/:bulkActionId",
({ params }) => {
const { spaceId, bulkActionId } = params
if (spaceId === "test-space-id" && bulkActionId === "test-bulk-action-id") {
return HttpResponse.json(mockBulkAction)
}
return new HttpResponse(null, { status: 404 })
},
),
]
const assetHandlers = [
// Upload asset
http.post(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/assets",
async ({ request, params }) => {
console.log("MSW: Handling asset creation request")
const { spaceId } = params
if (spaceId === "test-space-id") {
const body = (await request.json()) as {
fields: {
title: { "en-US": string }
description?: { "en-US": string }
file: {
"en-US": {
fileName: string
contentType: string
upload: string
}
}
}
}
return HttpResponse.json({
sys: {
id: "test-asset-id",
version: 1,
type: "Asset",
},
fields: body.fields,
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Process asset
http.put(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/assets/:assetId/files/en-US/process",
({ params }) => {
console.log("MSW: Handling asset processing request")
const { spaceId, assetId } = params
if (spaceId === "test-space-id" && assetId === "test-asset-id") {
return HttpResponse.json({
sys: {
id: "test-asset-id",
version: 2,
publishedVersion: 1,
},
fields: {
title: { "en-US": "Test Asset" },
description: { "en-US": "Test Description" },
file: {
"en-US": {
fileName: "test.jpg",
contentType: "image/jpeg",
url: "https://example.com/test.jpg",
},
},
},
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Get asset
http.get(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/assets/:assetId",
({ params }) => {
console.log("MSW: Handling get processed asset request")
const { spaceId, assetId } = params
if (spaceId === "test-space-id" && assetId === "test-asset-id") {
return HttpResponse.json({
sys: {
id: "test-asset-id",
version: 2,
type: "Asset",
publishedVersion: 1,
},
fields: {
title: { "en-US": "Test Asset" },
description: { "en-US": "Test Description" },
file: {
"en-US": {
fileName: "test.jpg",
contentType: "image/jpeg",
url: "https://example.com/test.jpg",
},
},
},
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Update asset
http.put(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/assets/:assetId",
({ params }) => {
const { spaceId, assetId } = params
if (spaceId === "test-space-id" && assetId === "test-asset-id") {
return HttpResponse.json({
sys: { id: "test-asset-id" },
fields: {
title: { "en-US": "Updated Asset" },
description: { "en-US": "Updated Description" },
},
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Delete asset
http.delete(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/assets/:assetId",
({ params }) => {
const { spaceId, assetId } = params
if (spaceId === "test-space-id" && assetId === "test-asset-id") {
return new HttpResponse(null, { status: 204 })
}
return new HttpResponse(null, { status: 404 })
},
),
// Publish asset
http.put(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/assets/:assetId/published",
({ params }) => {
const { spaceId, assetId } = params
if (spaceId === "test-space-id" && assetId === "test-asset-id") {
return HttpResponse.json({
sys: {
id: "test-asset-id",
publishedVersion: 1,
},
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Unpublish asset
http.delete(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/assets/:assetId/published",
({ params }) => {
const { spaceId, assetId } = params
if (spaceId === "test-space-id" && assetId === "test-asset-id") {
return HttpResponse.json({
sys: {
id: "test-asset-id",
},
})
}
return new HttpResponse(null, { status: 404 })
},
),
]
const entryHandlers = [
// Search entries
http.get(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/entries",
({ params, request }) => {
const { spaceId } = params
if (spaceId === "test-space-id") {
return HttpResponse.json({
items: [
{
sys: {
id: "test-entry-id",
contentType: { sys: { id: "test-content-type-id" } },
},
fields: {
title: { "en-US": "Test Entry" },
description: { "en-US": "Test Description" },
},
},
],
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Get specific entry
http.get(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/entries/:entryId",
({ params }) => {
const { spaceId, entryId } = params
if (spaceId === "test-space-id" && entryId === "test-entry-id") {
return HttpResponse.json({
sys: {
id: "test-entry-id",
contentType: { sys: { id: "test-content-type-id" } },
},
fields: {
title: { "en-US": "Test Entry" },
description: { "en-US": "Test Description" },
},
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Create entry
http.post(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/entries",
async ({ params, request }) => {
const { spaceId } = params
if (spaceId === "test-space-id") {
const contentType = request.headers.get("X-Contentful-Content-Type")
const body = (await request.json()) as {
fields: Record<string, any>
}
return HttpResponse.json({
sys: {
id: "new-entry-id",
type: "Entry",
contentType: {
sys: {
type: "Link",
linkType: "ContentType",
id: contentType,
},
},
},
fields: body.fields,
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Update entry
http.put(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/entries/:entryId",
async ({ params, request }) => {
const { spaceId, entryId } = params
if (spaceId === "test-space-id" && entryId === "test-entry-id") {
const body = (await request.json()) as {
fields: Record<string, any>
}
return HttpResponse.json({
sys: {
id: entryId,
contentType: { sys: { id: "test-content-type-id" } },
},
fields: body.fields,
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Delete entry
http.delete(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/entries/:entryId",
({ params }) => {
const { spaceId, entryId } = params
if (spaceId === "test-space-id" && entryId === "test-entry-id") {
return new HttpResponse(null, { status: 204 })
}
return new HttpResponse(null, { status: 404 })
},
),
// Publish entry
http.put(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/entries/:entryId/published",
({ params }) => {
const { spaceId, entryId } = params
if (spaceId === "test-space-id" && entryId === "test-entry-id") {
return HttpResponse.json({
sys: {
id: entryId,
publishedVersion: 1,
},
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Unpublish entry
http.delete(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/entries/:entryId/published",
({ params }) => {
const { spaceId, entryId } = params
if (spaceId === "test-space-id" && entryId === "test-entry-id") {
return HttpResponse.json({
sys: { id: entryId },
})
}
return new HttpResponse(null, { status: 404 })
},
),
]
const contentTypeHandlers = [
// List content types
http.get(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/content_types",
({ params }) => {
const { spaceId } = params
if (spaceId === "test-space-id") {
return HttpResponse.json({
items: [
{
sys: { id: "test-content-type-id" },
name: "Test Content Type",
fields: [
{
id: "title",
name: "Title",
type: "Text",
required: true,
},
],
},
],
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Get specific content type
http.get(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/content_types/:contentTypeId",
({ params }) => {
const { spaceId, contentTypeId } = params
if (spaceId === "test-space-id" && contentTypeId === "test-content-type-id") {
return HttpResponse.json({
sys: { id: "test-content-type-id" },
name: "Test Content Type",
fields: [
{
id: "title",
name: "Title",
type: "Text",
required: true,
},
],
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Create content type
http.post(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/content_types",
async ({ params, request }) => {
const { spaceId } = params
if (spaceId === "test-space-id") {
const body = (await request.json()) as {
name: string
fields: Array<{
id: string
name: string
type: string
required?: boolean
}>
description?: string
displayField?: string
}
return HttpResponse.json({
sys: { id: "new-content-type-id" },
name: body.name,
fields: body.fields,
description: body.description,
displayField: body.displayField,
})
}
return new HttpResponse(null, { status: 404 })
},
),
// create content type with ID
http.put(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/content_types/:contentTypeId",
async ({ params, request }) => {
const { spaceId, contentTypeId } = params
if (spaceId === "test-space-id") {
const body = (await request.json()) as {
name: string
fields: Array<{
id: string
name: string
type: string
required?: boolean
}>
description?: string
displayField?: string
}
return HttpResponse.json({
sys: { id: contentTypeId },
name: body.name,
fields: body.fields,
description: body.description,
displayField: body.displayField,
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Publish content type
http.put(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/content_types/:contentTypeId/published",
({ params }) => {
const { spaceId, contentTypeId } = params
if (spaceId === "test-space-id" && contentTypeId === "test-content-type-id") {
return HttpResponse.json({
sys: {
id: contentTypeId,
version: 1,
publishedVersion: 1,
},
name: "Test Content Type",
fields: [
{
id: "title",
name: "Title",
type: "Text",
required: true,
},
],
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Update content type
http.put(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/content_types/:contentTypeId",
async ({ params, request }) => {
const { spaceId, contentTypeId } = params
if (spaceId === "test-space-id" && contentTypeId === "test-content-type-id") {
const body = (await request.json()) as {
name: string
fields: Array<{
id: string
name: string
type: string
required?: boolean
}>
description?: string
displayField?: string
}
return HttpResponse.json({
sys: { id: contentTypeId },
name: body.name,
fields: body.fields,
description: body.description,
displayField: body.displayField,
})
}
return new HttpResponse(null, { status: 404 })
},
),
// Delete content type
http.delete(
"https://api.contentful.com/spaces/:spaceId/environments/:environmentId/content_types/:contentTypeId",
({ params }) => {
const { spaceId, contentTypeId } = params
if (spaceId === "test-space-id" && contentTypeId === "test-content-type-id") {
return new HttpResponse(null, { status: 204 })
}
return new HttpResponse(null, { status: 404 })
},
),
]
// Setup MSW Server
export const server = setupServer(
...handlers,
...assetHandlers,
...contentTypeHandlers,
...entryHandlers,
...bulkActionHandlers,
)
```
--------------------------------------------------------------------------------
/test/integration/comment-handler.test.ts:
--------------------------------------------------------------------------------
```typescript
/* eslint-disable @typescript-eslint/no-unused-expressions */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, vi } from "vitest"
import { commentHandlers } from "../../src/handlers/comment-handlers.js"
import { server } from "../msw-setup.js"
// Mock comment data
const mockComment = {
sys: {
id: "test-comment-id",
version: 1,
createdAt: "2023-01-01T00:00:00Z",
updatedAt: "2023-01-01T00:00:00Z",
createdBy: {
sys: { id: "test-user-id", type: "Link", linkType: "User" },
},
updatedBy: {
sys: { id: "test-user-id", type: "Link", linkType: "User" },
},
},
body: "This is a test comment",
status: "active",
}
const mockRichTextComment = {
sys: {
id: "test-rich-comment-id",
version: 1,
createdAt: "2023-01-01T00:00:00Z",
updatedAt: "2023-01-01T00:00:00Z",
createdBy: {
sys: { id: "test-user-id", type: "Link", linkType: "User" },
},
updatedBy: {
sys: { id: "test-user-id", type: "Link", linkType: "User" },
},
},
body: {
nodeType: "document",
content: [
{
nodeType: "paragraph",
content: [
{
nodeType: "text",
value: "This is a rich text comment",
marks: [],
},
],
},
],
},
status: "active",
}
const mockCommentsCollection = {
sys: { type: "Array" },
total: 2,
skip: 0,
limit: 100,
items: [mockComment, mockRichTextComment],
}
// Mock Contentful client comment methods
const mockCommentGetMany = vi.fn().mockResolvedValue(mockCommentsCollection)
const mockCommentCreate = vi.fn().mockResolvedValue(mockComment)
const mockCommentGet = vi.fn().mockResolvedValue(mockComment)
const mockCommentDelete = vi.fn().mockResolvedValue(undefined)
const mockCommentUpdate = vi.fn().mockResolvedValue(mockComment)
// Mock the contentful client for testing comment operations
vi.mock("../../src/config/client.js", async (importOriginal) => {
const originalModule = (await importOriginal()) as any
// Create a mock function that will be used for the content client
const getContentfulClient = vi.fn()
// Store the original function so we can call it if needed
const originalGetClient = originalModule.getContentfulClient
// Set up the mock function to return either the original or our mocked version
getContentfulClient.mockImplementation(async () => {
// Create our mock client
const mockClient = {
comment: {
getMany: mockCommentGetMany,
create: mockCommentCreate,
get: mockCommentGet,
delete: mockCommentDelete,
update: mockCommentUpdate,
},
// Pass through other methods to the original client
entry: {
get: (...args: any[]) =>
originalGetClient().then((client: any) => client.entry.get(...args)),
getMany: (...args: any[]) =>
originalGetClient().then((client: any) => client.entry.getMany(...args)),
create: (...args: any[]) =>
originalGetClient().then((client: any) => client.entry.create(...args)),
update: (...args: any[]) =>
originalGetClient().then((client: any) => client.entry.update(...args)),
delete: (...args: any[]) =>
originalGetClient().then((client: any) => client.entry.delete(...args)),
publish: (...args: any[]) =>
originalGetClient().then((client: any) => client.entry.publish(...args)),
unpublish: (...args: any[]) =>
originalGetClient().then((client: any) => client.entry.unpublish(...args)),
},
}
return mockClient
})
return {
...originalModule,
getContentfulClient,
}
})
describe("Comment Handlers Integration Tests", () => {
// Start MSW Server before tests
beforeAll(() => {
server.listen()
// Ensure environment variables are not set
delete process.env.SPACE_ID
delete process.env.ENVIRONMENT_ID
})
afterEach(() => {
server.resetHandlers()
vi.clearAllMocks()
})
afterAll(() => server.close())
const testSpaceId = "test-space-id"
const testEnvironmentId = "master"
const testEntryId = "test-entry-id"
const testCommentId = "test-comment-id"
describe("getComments", () => {
it("should retrieve comments for an entry with default parameters", async () => {
const result = await commentHandlers.getComments({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
})
expect(mockCommentGetMany).toHaveBeenCalledWith({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
bodyFormat: "plain-text",
query: { status: "active" },
})
expect(result).to.have.property("content").that.is.an("array")
expect(result.content).to.have.lengthOf(1)
const responseData = JSON.parse(result.content[0].text)
expect(responseData.items).to.be.an("array")
expect(responseData.total).to.equal(2)
expect(responseData.showing).to.equal(2)
expect(responseData.remaining).to.equal(0)
})
it("should retrieve comments with rich-text body format", async () => {
const result = await commentHandlers.getComments({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
bodyFormat: "rich-text",
})
expect(mockCommentGetMany).toHaveBeenCalledWith({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
bodyFormat: "rich-text",
query: { status: "active" },
})
expect(result).to.have.property("content")
const responseData = JSON.parse(result.content[0].text)
expect(responseData.items).to.be.an("array")
})
it("should retrieve comments with status filter", async () => {
const result = await commentHandlers.getComments({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
status: "resolved",
})
expect(mockCommentGetMany).toHaveBeenCalledWith({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
bodyFormat: "plain-text",
query: { status: "resolved" },
})
expect(result).to.have.property("content")
})
it("should retrieve all comments when status is 'all'", async () => {
const result = await commentHandlers.getComments({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
status: "all",
})
expect(mockCommentGetMany).toHaveBeenCalledWith({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
bodyFormat: "plain-text",
query: {},
})
expect(result).to.have.property("content")
})
it("should use environment variables when provided", async () => {
// Set environment variables
const originalSpaceId = process.env.SPACE_ID
const originalEnvironmentId = process.env.ENVIRONMENT_ID
process.env.SPACE_ID = "env-space-id"
process.env.ENVIRONMENT_ID = "env-environment-id"
const result = await commentHandlers.getComments({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
})
expect(mockCommentGetMany).toHaveBeenCalledWith({
spaceId: "env-space-id",
environmentId: "env-environment-id",
entryId: testEntryId,
bodyFormat: "plain-text",
query: { status: "active" },
})
// Restore environment variables
process.env.SPACE_ID = originalSpaceId
process.env.ENVIRONMENT_ID = originalEnvironmentId
expect(result).to.have.property("content")
})
it("should handle pagination with limit parameter", async () => {
const result = await commentHandlers.getComments({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
limit: 1,
})
const responseData = JSON.parse(result.content[0].text)
expect(responseData.items).to.have.lengthOf(1)
expect(responseData.total).to.equal(2)
expect(responseData.showing).to.equal(1)
expect(responseData.remaining).to.equal(1)
expect(responseData.skip).to.equal(1)
expect(responseData.message).to.include("skip parameter")
})
it("should handle pagination with skip parameter", async () => {
const result = await commentHandlers.getComments({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
limit: 1,
skip: 1,
})
const responseData = JSON.parse(result.content[0].text)
expect(responseData.items).to.have.lengthOf(1)
expect(responseData.total).to.equal(2)
expect(responseData.showing).to.equal(1)
expect(responseData.remaining).to.equal(0)
expect(responseData.skip).to.be.undefined
expect(responseData.message).to.be.undefined
})
it("should handle limit larger than available items", async () => {
const result = await commentHandlers.getComments({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
limit: 10,
})
const responseData = JSON.parse(result.content[0].text)
expect(responseData.items).to.have.lengthOf(2)
expect(responseData.total).to.equal(2)
expect(responseData.showing).to.equal(2)
expect(responseData.remaining).to.equal(0)
expect(responseData.skip).to.be.undefined
expect(responseData.message).to.be.undefined
})
})
describe("createComment", () => {
it("should create a plain-text comment with default parameters", async () => {
const testBody = "This is a test comment"
const result = await commentHandlers.createComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
body: testBody,
})
expect(mockCommentCreate).toHaveBeenCalledWith(
{
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
},
{
body: testBody,
status: "active",
},
)
expect(result).to.have.property("content").that.is.an("array")
expect(result.content).to.have.lengthOf(1)
const responseData = JSON.parse(result.content[0].text)
expect(responseData.sys.id).to.equal("test-comment-id")
expect(responseData.body).to.equal("This is a test comment")
expect(responseData.status).to.equal("active")
})
it("should create a rich-text comment", async () => {
const testBody = "This is a rich text comment"
mockCommentCreate.mockResolvedValueOnce(mockRichTextComment)
const result = await commentHandlers.createComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
body: testBody,
})
expect(mockCommentCreate).toHaveBeenCalledWith(
{
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
},
{
body: testBody,
status: "active",
},
)
expect(result).to.have.property("content")
const responseData = JSON.parse(result.content[0].text)
expect(responseData.sys.id).to.equal("test-rich-comment-id")
})
it("should create a comment with custom status", async () => {
const testBody = "This is a test comment"
const result = await commentHandlers.createComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
body: testBody,
status: "active",
})
expect(mockCommentCreate).toHaveBeenCalledWith(
{
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
},
{
body: testBody,
status: "active",
},
)
expect(result).to.have.property("content")
})
it("should use environment variables when provided", async () => {
// Set environment variables
const originalSpaceId = process.env.SPACE_ID
const originalEnvironmentId = process.env.ENVIRONMENT_ID
process.env.SPACE_ID = "env-space-id"
process.env.ENVIRONMENT_ID = "env-environment-id"
const testBody = "This is a test comment"
const result = await commentHandlers.createComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
body: testBody,
})
expect(mockCommentCreate).toHaveBeenCalledWith(
{
spaceId: "env-space-id",
environmentId: "env-environment-id",
entryId: testEntryId,
},
{
body: testBody,
status: "active",
},
)
// Restore environment variables
process.env.SPACE_ID = originalSpaceId
process.env.ENVIRONMENT_ID = originalEnvironmentId
expect(result).to.have.property("content")
})
})
describe("getSingleComment", () => {
it("should retrieve a specific comment with default parameters", async () => {
const result = await commentHandlers.getSingleComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
})
expect(mockCommentGet).toHaveBeenCalledWith({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
bodyFormat: "plain-text",
})
expect(result).to.have.property("content").that.is.an("array")
expect(result.content).to.have.lengthOf(1)
const responseData = JSON.parse(result.content[0].text)
expect(responseData.sys.id).to.equal("test-comment-id")
expect(responseData.body).to.equal("This is a test comment")
expect(responseData.status).to.equal("active")
})
it("should retrieve a specific comment with rich-text body format", async () => {
mockCommentGet.mockResolvedValueOnce(mockRichTextComment)
const result = await commentHandlers.getSingleComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
bodyFormat: "rich-text",
})
expect(mockCommentGet).toHaveBeenCalledWith({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
bodyFormat: "rich-text",
})
expect(result).to.have.property("content")
const responseData = JSON.parse(result.content[0].text)
expect(responseData.body).to.have.property("nodeType", "document")
})
it("should use environment variables when provided", async () => {
// Set environment variables
const originalSpaceId = process.env.SPACE_ID
const originalEnvironmentId = process.env.ENVIRONMENT_ID
process.env.SPACE_ID = "env-space-id"
process.env.ENVIRONMENT_ID = "env-environment-id"
const result = await commentHandlers.getSingleComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
})
expect(mockCommentGet).toHaveBeenCalledWith({
spaceId: "env-space-id",
environmentId: "env-environment-id",
entryId: testEntryId,
commentId: testCommentId,
bodyFormat: "plain-text",
})
// Restore environment variables
process.env.SPACE_ID = originalSpaceId
process.env.ENVIRONMENT_ID = originalEnvironmentId
expect(result).to.have.property("content")
})
it("should handle errors gracefully", async () => {
mockCommentGet.mockRejectedValueOnce(new Error("Comment not found"))
try {
await commentHandlers.getSingleComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: "invalid-comment-id",
})
expect.fail("Should have thrown an error")
} catch (error: any) {
expect(error).to.exist
expect(error.message).to.equal("Comment not found")
}
})
})
describe("deleteComment", () => {
it("should delete a specific comment", async () => {
const result = await commentHandlers.deleteComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
})
expect(mockCommentDelete).toHaveBeenCalledWith({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
version: 1,
})
expect(result).to.have.property("content").that.is.an("array")
expect(result.content).to.have.lengthOf(1)
expect(result.content[0].text).to.include(
`Successfully deleted comment ${testCommentId} from entry ${testEntryId}`,
)
})
it("should use environment variables when provided", async () => {
// Set environment variables
const originalSpaceId = process.env.SPACE_ID
const originalEnvironmentId = process.env.ENVIRONMENT_ID
process.env.SPACE_ID = "env-space-id"
process.env.ENVIRONMENT_ID = "env-environment-id"
const result = await commentHandlers.deleteComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
})
expect(mockCommentDelete).toHaveBeenCalledWith({
spaceId: "env-space-id",
environmentId: "env-environment-id",
entryId: testEntryId,
commentId: testCommentId,
version: 1,
})
// Restore environment variables
process.env.SPACE_ID = originalSpaceId
process.env.ENVIRONMENT_ID = originalEnvironmentId
expect(result).to.have.property("content")
})
it("should handle errors gracefully", async () => {
mockCommentDelete.mockRejectedValueOnce(new Error("Delete failed"))
try {
await commentHandlers.deleteComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: "invalid-comment-id",
})
expect.fail("Should have thrown an error")
} catch (error) {
expect(error).to.exist
expect(error.message).to.equal("Delete failed")
}
})
})
describe("updateComment", () => {
it("should update a comment with plain-text format", async () => {
const testBody = "Updated comment body"
const testStatus = "resolved"
const result = await commentHandlers.updateComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
body: testBody,
status: testStatus,
})
expect(mockCommentUpdate).toHaveBeenCalledWith(
{
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
},
{
body: testBody,
status: testStatus,
version: 1,
},
)
expect(result).to.have.property("content").that.is.an("array")
expect(result.content).to.have.lengthOf(1)
const responseData = JSON.parse(result.content[0].text)
expect(responseData.sys.id).to.equal("test-comment-id")
})
it("should update a comment with rich-text format", async () => {
const testBody = "Updated rich text comment"
mockCommentUpdate.mockResolvedValueOnce(mockRichTextComment)
const result = await commentHandlers.updateComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
body: testBody,
bodyFormat: "rich-text",
})
expect(mockCommentUpdate).toHaveBeenCalledWith(
{
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
},
{
body: testBody,
version: 1,
},
)
expect(result).to.have.property("content")
const responseData = JSON.parse(result.content[0].text)
expect(responseData.sys.id).to.equal("test-rich-comment-id")
})
it("should update only body when status is not provided", async () => {
const testBody = "Updated comment body only"
const result = await commentHandlers.updateComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
body: testBody,
})
expect(mockCommentUpdate).toHaveBeenCalledWith(
{
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
},
{
body: testBody,
version: 1,
},
)
expect(result).to.have.property("content")
})
it("should update only status when body is not provided", async () => {
const testStatus = "resolved"
const result = await commentHandlers.updateComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
status: testStatus,
})
expect(mockCommentUpdate).toHaveBeenCalledWith(
{
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
},
{
status: testStatus,
version: 1,
},
)
expect(result).to.have.property("content")
})
it("should use environment variables when provided", async () => {
// Set environment variables
const originalSpaceId = process.env.SPACE_ID
const originalEnvironmentId = process.env.ENVIRONMENT_ID
process.env.SPACE_ID = "env-space-id"
process.env.ENVIRONMENT_ID = "env-environment-id"
const testBody = "Updated comment"
const result = await commentHandlers.updateComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
body: testBody,
})
expect(mockCommentUpdate).toHaveBeenCalledWith(
{
spaceId: "env-space-id",
environmentId: "env-environment-id",
entryId: testEntryId,
commentId: testCommentId,
},
{
body: testBody,
version: 1,
},
)
// Restore environment variables
process.env.SPACE_ID = originalSpaceId
process.env.ENVIRONMENT_ID = originalEnvironmentId
expect(result).to.have.property("content")
})
it("should handle errors gracefully", async () => {
mockCommentUpdate.mockRejectedValueOnce(new Error("Update failed"))
try {
await commentHandlers.updateComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: "invalid-comment-id",
body: "Test body",
})
expect.fail("Should have thrown an error")
} catch (error) {
expect(error).to.exist
expect(error.message).to.equal("Update failed")
}
})
})
describe("Error handling", () => {
it("should handle getComments API errors", async () => {
mockCommentGetMany.mockRejectedValueOnce(new Error("API Error"))
try {
await commentHandlers.getComments({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
})
expect.fail("Should have thrown an error")
} catch (error) {
expect(error).to.exist
expect(error.message).to.equal("API Error")
}
})
it("should handle createComment API errors", async () => {
mockCommentCreate.mockRejectedValueOnce(new Error("Create failed"))
try {
await commentHandlers.createComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
body: "Test comment",
})
expect.fail("Should have thrown an error")
} catch (error) {
expect(error).to.exist
expect(error.message).to.equal("Create failed")
}
})
it("should handle deleteComment API errors", async () => {
mockCommentDelete.mockRejectedValueOnce(new Error("Delete failed"))
try {
await commentHandlers.deleteComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
})
expect.fail("Should have thrown an error")
} catch (error) {
expect(error).to.exist
expect(error.message).to.equal("Delete failed")
}
})
it("should handle updateComment API errors", async () => {
mockCommentUpdate.mockRejectedValueOnce(new Error("Update failed"))
try {
await commentHandlers.updateComment({
spaceId: testSpaceId,
environmentId: testEnvironmentId,
entryId: testEntryId,
commentId: testCommentId,
body: "Test body",
})
expect.fail("Should have thrown an error")
} catch (error) {
expect(error).to.exist
expect(error.message).to.equal("Update failed")
}
})
})
})
```
--------------------------------------------------------------------------------
/src/prompts/generateVariableTypeContent.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Generate detailed content for AI Action variable types
* @param variableType Specific variable type to generate content for
* @returns Detailed explanation of the variable type(s)
*/
export function generateVariableTypeContent(variableType?: string): string {
// If no specific type is requested, return an overview of all types
if (!variableType) {
return `AI Actions in Contentful use variables to make templates dynamic and adaptable. These variables serve as placeholders in your prompt templates that get replaced with actual values when an AI Action is invoked.
## Available Variable Types
### 1. StandardInput
The primary input variable for text content. Ideal for the main content that needs processing.
- **Use case**: Processing existing content, like rewriting or improving text
- **Template usage**: {{input_text}}
- **MCP parameter**: Usually exposed as "input_text"
- **Best for**: Main content field being operated on
### 2. Text
Simple text variables for additional information or context.
- **Use case**: Adding supplementary information to guide the AI
- **Template usage**: {{context}}, {{guidelines}}
- **Configuration**: Can be strict (limit to specific values) or free-form
- **Best for**: Providing context, instructions, or metadata
### 3. FreeFormInput
Unstructured text input with minimal constraints.
- **Use case**: Open-ended user input where flexibility is needed
- **Template usage**: {{user_instructions}}
- **Best for**: Custom requests or specific directions
### 4. StringOptionsList
Predefined options presented as a dropdown menu.
- **Use case**: Selecting from a fixed set of choices (tone, style, etc.)
- **Template usage**: {{tone}}, {{style}}
- **Configuration**: Requires defining the list of available options
- **Best for**: Consistent, controlled parameters like tones or formatting options
### 5. Reference
Links to other Contentful entries.
- **Use case**: Using content from other entries as context
- **Template usage**: Content accessed via helpers: "{{reference.fields.title}}"
- **Configuration**: Can restrict to specific content types
- **Special properties**: When using in MCP, requires "*_path" parameter to specify field path
- **Best for**: Cross-referencing content for context or expansion
### 6. MediaReference
Links to media assets like images, videos.
- **Use case**: Generating descriptions from media, processing asset metadata
- **Configuration**: Points to a specific asset in your space
- **Special properties**: When using in MCP, requires "*_path" parameter to specify which part to process
- **Best for**: Image description, alt text generation, media analysis
### 7. Locale
Specifies the language or region for content.
- **Use case**: Translation, localization, region-specific content
- **Template usage**: {{locale}}
- **Format**: Language codes like "en-US", "de-DE"
- **Best for**: Multi-language operations or locale-specific formatting
## Best Practices for Variables
1. **Use descriptive names**: Make variable names intuitive ({{product_name}} not {{var1}})
2. **Provide clear descriptions**: Help users understand what each variable does
3. **Use appropriate types**: Match variable types to their purpose
4. **Set sensible defaults**: Pre-populate where possible to guide users
5. **Consider field paths**: For Reference and MediaReference variables, remember that users need to specify which field to access
## Template Integration
Variables are referenced in templates using double curly braces: {{variable_id}}
Example template with multiple variable types:
\`\`\`
You are a content specialist helping improve product descriptions.
TONE: {{tone}}
AUDIENCE: Customers interested in {{target_market}}
ORIGINAL CONTENT:
{{input_text}}
INSTRUCTIONS:
Rewrite the above product description to be more engaging, maintaining the key product details but optimizing for {{tone}} tone and the {{target_market}} market.
IMPROVED DESCRIPTION:
\`\`\`
## Working with References
When working with References and MediaReferences, remember that the content must be accessed via the correct path. In the MCP integration, this is handled through separate parameters (e.g., "reference" and "reference_path").
## Variable Validation
AI Actions validate variables at runtime to ensure they meet requirements. Configure validation rules appropriately to prevent errors during invocation.`;
}
// Generate content for specific variable types
switch (variableType.toLowerCase()) {
case "standardinput":
case "standard input":
return `## StandardInput Variable Type
The StandardInput is the primary input variable for text content in AI Actions. It's designed to handle the main content that needs to be processed by the AI model.
### Purpose
This variable type is typically used for:
- The primary text to be transformed
- Existing content that needs enhancement, rewriting, or analysis
- The core content that the AI Action will operate on
### Configuration
StandardInput has minimal configuration needs:
- **ID**: A unique identifier (e.g., "main_content")
- **Name**: User-friendly label (e.g., "Main Content")
- **Description**: Clear explanation (e.g., "The content to be processed")
No additional configuration properties are required for this type.
### In MCP Integration
When working with the MCP integration, StandardInput variables are typically exposed with the parameter name "input_text" for consistency and clarity.
### Template Usage
In your AI Action template, reference StandardInput variables using double curly braces:
\`\`\`
ORIGINAL CONTENT:
{{input_text}}
Please improve the above content by...
\`\`\`
### Examples
**Example 1: Content Enhancement**
\`\`\`
You are a content specialist.
ORIGINAL CONTENT:
{{input_text}}
Enhance the above content by improving clarity, fixing grammar, and making it more engaging while preserving the key information.
IMPROVED CONTENT:
\`\`\`
**Example 2: SEO Optimization**
\`\`\`
You are an SEO expert.
ORIGINAL CONTENT:
{{input_text}}
KEYWORDS: {{keywords}}
Rewrite the above content to optimize for SEO using the provided keywords. Maintain the core message but improve readability and keyword usage.
SEO-OPTIMIZED CONTENT:
\`\`\`
### Best Practices
1. **Clear instructions**: Always include clear directions about what to do with the input text
2. **Context setting**: Provide context about what the input represents (e.g., product description, blog post)
3. **Output expectations**: Clearly indicate what the expected output format should be
4. **Complementary variables**: Pair StandardInput with other variables that provide direction (tone, style, keywords)
### Implementation with MCP Tools
When creating an AI Action with StandardInput using MCP tools:
\`\`\`javascript
create_ai_action({
// other parameters...
instruction: {
template: "You are helping improve content...\\n\\nORIGINAL CONTENT:\\n{{input_text}}\\n\\nIMPROVED CONTENT:",
variables: [
{
id: "input_text",
type: "StandardInput",
name: "Input Content",
description: "The content to be improved"
}
// other variables...
]
}
});
\`\`\``;
case "text":
return `## Text Variable Type
The Text variable type provides a simple way to collect text input in AI Actions. It's more flexible than StringOptionsList but can include validation constraints if needed.
### Purpose
Text variables are used for:
- Supplementary information to guide the AI
- Additional context that affects output
- Simple inputs that don't require the full flexibility of FreeFormInput
### Configuration
Text variables can be configured with these properties:
- **ID**: Unique identifier (e.g., "brand_guidelines")
- **Name**: User-friendly label (e.g., "Brand Guidelines")
- **Description**: Explanation of what to input
- **Configuration** (optional):
- **strict**: Boolean indicating whether values are restricted
- **in**: Array of allowed values if strict is true
### Template Usage
Reference Text variables in templates using double curly braces:
\`\`\`
BRAND GUIDELINES: {{brand_guidelines}}
CONTENT:
{{input_text}}
Please rewrite the above content following the brand guidelines provided.
\`\`\`
### Examples
**Example 1: Simple Text Variable**
\`\`\`javascript
{
id: "customer_segment",
type: "Text",
name: "Customer Segment",
description: "The target customer segment for this content"
}
\`\`\`
**Example 2: Text Variable with Validation**
\`\`\`javascript
{
id: "priority_level",
type: "Text",
name: "Priority Level",
description: "The priority level for this task",
configuration: {
strict: true,
in: ["High", "Medium", "Low"]
}
}
\`\`\`
### Best Practices
1. **Clarify expectations**: Provide clear descriptions about what information is expected
2. **Use validation when appropriate**: If only certain values are valid, use the strict configuration
3. **Consider using StringOptionsList**: If you have a fixed set of options, StringOptionsList may be more appropriate
4. **Keep it focused**: Ask for specific information rather than general input
### Implementation with MCP Tools
\`\`\`javascript
create_ai_action({
// other parameters...
instruction: {
template: "Create content with customer segment {{customer_segment}} in mind...",
variables: [
{
id: "customer_segment",
type: "Text",
name: "Customer Segment",
description: "The target customer segment for this content"
}
// other variables...
]
}
});
\`\`\``;
case "freeforminput":
case "free form input":
return `## FreeFormInput Variable Type
The FreeFormInput variable type provides the most flexibility for collecting user input in AI Actions. It's designed for open-ended text entry with minimal constraints.
### Purpose
FreeFormInput variables are ideal for:
- Custom instructions from users
- Specific guidance that can't be predetermined
- Open-ended information that requires flexibility
### Configuration
FreeFormInput has minimal configuration requirements:
- **ID**: Unique identifier (e.g., "special_instructions")
- **Name**: User-friendly label (e.g., "Special Instructions")
- **Description**: Clear guidance on what kind of input is expected
No additional configuration properties are typically needed.
### Template Usage
Reference FreeFormInput variables in templates with double curly braces:
\`\`\`
CONTENT:
{{input_text}}
SPECIAL INSTRUCTIONS:
{{special_instructions}}
Please modify the content above according to the special instructions provided.
\`\`\`
### Examples
**Example: Content Creation Guidance**
\`\`\`javascript
{
id: "author_preferences",
type: "FreeFormInput",
name: "Author Preferences",
description: "Any specific preferences or requirements from the author that should be considered"
}
\`\`\`
### Best Practices
1. **Provide guidance**: Even though it's free-form, give users clear guidance about what kind of input is helpful
2. **Set expectations**: Explain how the input will be used in the AI Action
3. **Use sparingly**: Too many free-form inputs can make AI Actions confusing - use only where flexibility is needed
4. **Position appropriately**: Place FreeFormInput variables where they make most sense in your template flow
### When to Use FreeFormInput vs. Text
- Use **FreeFormInput** when you need completely open-ended input without restrictions
- Use **Text** when you want simple input that might benefit from validation
### Implementation with MCP Tools
\`\`\`javascript
create_ai_action({
// other parameters...
instruction: {
template: "Generate content based on these specifications...\\n\\nSPECIAL REQUIREMENTS:\\n{{special_requirements}}",
variables: [
{
id: "special_requirements",
type: "FreeFormInput",
name: "Special Requirements",
description: "Any special requirements or preferences for the generated content"
}
// other variables...
]
}
});
\`\`\``;
case "stringoptionslist":
case "string options list":
return `## StringOptionsList Variable Type
The StringOptionsList variable type provides a dropdown menu of predefined options. It's ideal for scenarios where users should select from a fixed set of choices.
### Purpose
StringOptionsList variables are perfect for:
- Tone selection (formal, casual, etc.)
- Content categories or types
- Predefined styles or formats
- Any parameter with a limited set of valid options
### Configuration
StringOptionsList requires these configuration properties:
- **ID**: Unique identifier (e.g., "tone")
- **Name**: User-friendly label (e.g., "Content Tone")
- **Description**: Explanation of what the options represent
- **Configuration** (required):
- **values**: Array of string options to display
- **allowFreeFormInput** (optional): Boolean indicating if custom values are allowed
### Template Usage
Reference StringOptionsList variables in templates using double curly braces:
\`\`\`
TONE: {{tone}}
CONTENT:
{{input_text}}
Please rewrite the above content using a {{tone}} tone.
\`\`\`
### Examples
**Example 1: Tone Selection**
\`\`\`javascript
{
id: "tone",
type: "StringOptionsList",
name: "Content Tone",
description: "The tone to use for the content",
configuration: {
values: ["Formal", "Professional", "Casual", "Friendly", "Humorous"],
allowFreeFormInput: false
}
}
\`\`\`
**Example 2: Content Format with Custom Option**
\`\`\`javascript
{
id: "format",
type: "StringOptionsList",
name: "Content Format",
description: "The format for the generated content",
configuration: {
values: ["Blog Post", "Social Media", "Email", "Product Description", "Press Release"],
allowFreeFormInput: true
}
}
\`\`\`
### Best Practices
1. **Limit options**: Keep the list reasonably short (typically 3-7 options)
2. **Use clear labels**: Make option names self-explanatory
3. **Order logically**: Arrange options in a logical order (alphabetical, frequency, etc.)
4. **Consider defaults**: Place commonly used options earlier in the list
5. **Use allowFreeFormInput sparingly**: Only enable when custom options are truly needed
### In MCP Integration
In the MCP implementation, StringOptionsList variables are presented as enum parameters with the predefined options as choices.
### Implementation with MCP Tools
\`\`\`javascript
create_ai_action({
// other parameters...
instruction: {
template: "Generate a {{content_type}} about {{topic}}...",
variables: [
{
id: "content_type",
type: "StringOptionsList",
name: "Content Type",
description: "The type of content to generate",
configuration: {
values: ["Blog Post", "Social Media Post", "Newsletter", "Product Description"],
allowFreeFormInput: false
}
},
{
id: "topic",
type: "Text",
name: "Topic",
description: "The topic for the content"
}
// other variables...
]
}
});
\`\`\``;
case "reference":
return `## Reference Variable Type
The Reference variable type allows AI Actions to access content from other entries in your Contentful space, creating powerful content relationships and context-aware operations.
### Purpose
Reference variables are used for:
- Accessing content from related entries
- Processing entry data for context or analysis
- Creating content based on existing entries
- Cross-referencing information across multiple content types
### Configuration
Reference variables require these properties:
- **ID**: Unique identifier (e.g., "product_entry")
- **Name**: User-friendly label (e.g., "Product Entry")
- **Description**: Explanation of what entry to reference
- **Configuration** (optional):
- **allowedEntities**: Array of entity types that can be referenced (typically ["Entry"])
### Field Path Specification
When using References in MCP, you must provide both:
1. The entry ID (which entry to reference)
2. The field path (which field within that entry to use)
This is handled through two parameters:
- **reference**: The entry ID to reference
- **reference_path**: The path to the field (e.g., "fields.description.en-US")
### Template Usage
In templates, you can access referenced entry fields using helpers or direct field access:
\`\`\`
PRODUCT NAME: {{product_entry.fields.name}}
CURRENT DESCRIPTION:
{{product_entry.fields.description}}
Please generate an improved product description that highlights the key features while maintaining brand voice.
\`\`\`
### Examples
**Example: Product Description Generator**
\`\`\`javascript
{
id: "product_entry",
type: "Reference",
name: "Product Entry",
description: "The product entry to generate content for",
configuration: {
allowedEntities: ["Entry"]
}
}
\`\`\`
### Best Practices
1. **Clear field paths**: Always specify exactly which field to use from the referenced entry
2. **Provide context**: Explain which content type or entry type should be referenced
3. **Consider localization**: Remember that fields may be localized, so paths typically include locale code
4. **Check existence**: Handle cases where referenced fields might be empty
5. **Document requirements**: Clearly explain which entry types are valid for the reference
### MCP Implementation Notes
When using Reference variables with the MCP server:
1. The dynamic tool will include two parameters for each Reference:
- The reference ID parameter (e.g., "product_entry")
- The path parameter (e.g., "product_entry_path")
2. Always specify both when invoking the AI Action:
\`\`\`javascript
invoke_ai_action_product_description({
product_entry: "6tFnSQdgHuWYOk8eICA0w",
product_entry_path: "fields.description.en-US"
});
\`\`\`
### Implementation with MCP Tools
\`\`\`javascript
create_ai_action({
// other parameters...
instruction: {
template: "Generate SEO metadata for this product...\\n\\nPRODUCT: {{product.fields.title}}\\n\\nDESCRIPTION: {{product.fields.description}}",
variables: [
{
id: "product",
type: "Reference",
name: "Product Entry",
description: "The product entry to create metadata for",
configuration: {
allowedEntities: ["Entry"]
}
}
// other variables...
]
}
});
\`\`\`
When invoking this AI Action via MCP, you would provide both the entry ID and the specific fields to process.`;
case "mediareference":
case "media reference":
return `## MediaReference Variable Type
The MediaReference variable type enables AI Actions to work with digital assets such as images, videos, documents, and other media files in your Contentful space.
### Purpose
MediaReference variables are ideal for:
- Generating descriptions for images
- Creating alt text for accessibility
- Analyzing media content
- Processing metadata from assets
- Working with document content
### Configuration
MediaReference variables require these properties:
- **ID**: Unique identifier (e.g., "product_image")
- **Name**: User-friendly label (e.g., "Product Image")
- **Description**: Explanation of what asset to reference
- **Configuration**: Typically minimal, as it's restricted to assets
### Field Path Specification
Similar to References, when using MediaReferences in MCP, you need to provide:
1. The asset ID (which media asset to reference)
2. The field path (which aspect of the asset to use)
This is handled through two parameters:
- **media**: The asset ID to reference
- **media_path**: The path to the field (e.g., "fields.file.en-US.url" or "fields.title.en-US")
### Template Usage
In templates, you can access asset properties:
\`\`\`
IMAGE URL: {{product_image.fields.file.url}}
IMAGE TITLE: {{product_image.fields.title}}
Please generate an SEO-friendly alt text description for this product image that highlights key visual elements.
\`\`\`
### Examples
**Example: Image Alt Text Generator**
\`\`\`javascript
{
id: "product_image",
type: "MediaReference",
name: "Product Image",
description: "The product image to generate alt text for"
}
\`\`\`
### Best Practices
1. **Specify asset type**: Clearly indicate what type of asset should be referenced (image, video, etc.)
2. **Include guidance**: Explain what aspect of the asset will be processed
3. **Consider asset metadata**: Remember that assets have both file data and metadata fields
4. **Handle different asset types**: If your AI Action supports multiple asset types, provide clear instructions
### MCP Implementation Notes
When using MediaReference variables with the MCP server:
1. The dynamic tool will include two parameters for each MediaReference:
- The media reference parameter (e.g., "product_image")
- The path parameter (e.g., "product_image_path")
2. Always specify both when invoking the AI Action:
\`\`\`javascript
invoke_ai_action_alt_text_generator({
product_image: "7tGnRQegIvWZPj9eICA1q",
product_image_path: "fields.file.en-US"
});
\`\`\`
### Common Path Values
- **fields.file.{locale}**: To access the file data
- **fields.title.{locale}**: To access the asset title
- **fields.description.{locale}**: To access the asset description
### Implementation with MCP Tools
\`\`\`javascript
create_ai_action({
// other parameters...
instruction: {
template: "Generate an SEO-friendly alt text for this image...\\n\\nImage context: {{image_context}}\\n\\nProduct category: {{product_category}}",
variables: [
{
id: "product_image",
type: "MediaReference",
name: "Product Image",
description: "The product image to generate alt text for"
},
{
id: "image_context",
type: "Text",
name: "Image Context",
description: "Additional context about the image"
},
{
id: "product_category",
type: "StringOptionsList",
name: "Product Category",
description: "The category of the product",
configuration: {
values: ["Apparel", "Electronics", "Home", "Beauty", "Food"]
}
}
]
}
});
\`\`\`
When invoking this action via MCP, you would provide the asset ID and the specific field path to process.`;
case "locale":
return `## Locale Variable Type
The Locale variable type allows AI Actions to work with specific languages and regions, enabling localization and translation workflows in your content operations.
### Purpose
Locale variables are perfect for:
- Translation operations
- Region-specific content generation
- Language-aware content processing
- Multilingual content workflows
### Configuration
Locale variables have straightforward configuration:
- **ID**: Unique identifier (e.g., "target_language")
- **Name**: User-friendly label (e.g., "Target Language")
- **Description**: Explanation of how the locale will be used
No additional configuration properties are typically required.
### Format
Locale values follow the standard language-country format:
- **Language code**: 2-letter ISO language code (e.g., "en", "de", "fr")
- **Country/region code**: 2-letter country code (e.g., "US", "DE", "FR")
- **Combined**: language-country (e.g., "en-US", "de-DE", "fr-FR")
### Template Usage
Reference Locale variables in templates using double curly braces:
\`\`\`
ORIGINAL CONTENT (en-US):
{{input_text}}
Please translate the above content into {{target_locale}}.
TRANSLATED CONTENT ({{target_locale}}):
\`\`\`
### Examples
**Example: Content Translation**
\`\`\`javascript
{
id: "target_locale",
type: "Locale",
name: "Target Language",
description: "The language to translate the content into"
}
\`\`\`
### Best Practices
1. **Clear descriptions**: Specify whether you're looking for target or source language
2. **Validate locale format**: Ensure users enter valid locale codes (typically managed by the UI)
3. **Consider language variants**: Be clear about regional differences (e.g., en-US vs. en-GB)
4. **Use with other variables**: Combine with StandardInput for the content to be localized
### MCP Implementation
In the MCP integration, Locale variables are typically presented as string parameters with descriptions that guide users to enter valid locale codes.
### Implementation with MCP Tools
\`\`\`javascript
create_ai_action({
// other parameters...
instruction: {
template: "Translate the following content into {{target_locale}}...\\n\\nORIGINAL CONTENT:\\n{{input_text}}\\n\\nTRANSLATED CONTENT:",
variables: [
{
id: "target_locale",
type: "Locale",
name: "Target Language",
description: "The language to translate the content into (e.g., fr-FR, de-DE, ja-JP)"
},
{
id: "input_text",
type: "StandardInput",
name: "Content to Translate",
description: "The content that needs to be translated"
}
]
}
});
\`\`\`
When invoking this action via MCP:
\`\`\`javascript
invoke_ai_action_translator({
target_locale: "de-DE",
input_text: "Welcome to our store. We offer the best products at competitive prices."
});
\`\`\`
### Common Locale Codes
- **English**: en-US, en-GB, en-AU, en-CA
- **Spanish**: es-ES, es-MX, es-AR
- **French**: fr-FR, fr-CA
- **German**: de-DE, de-AT, de-CH
- **Japanese**: ja-JP
- **Chinese**: zh-CN, zh-TW
- **Portuguese**: pt-PT, pt-BR
- **Italian**: it-IT
- **Dutch**: nl-NL
- **Russian**: ru-RU`;
default:
return `# AI Action Variables: ${variableType}
The variable type "${variableType}" doesn't match any of the standard Contentful AI Action variable types. The standard types are:
1. StandardInput
2. Text
3. FreeFormInput
4. StringOptionsList
5. Reference
6. MediaReference
7. Locale
Please check the spelling or request information about one of these standard types for detailed guidance.`;
}
}
```
--------------------------------------------------------------------------------
/src/types/tools.ts:
--------------------------------------------------------------------------------
```typescript
// Define interface for config parameter
interface ConfigSchema {
type: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
properties: Record<string, any>
required?: string[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any
}
export const getSpaceEnvProperties = (config: ConfigSchema): ConfigSchema => {
const spaceEnvProperties = {
spaceId: {
type: "string",
description:
"The ID of the Contentful space. This must be the space's ID, not its name, ask for this ID if it's unclear.",
},
environmentId: {
type: "string",
description:
"The ID of the environment within the space, by default this will be called Master",
default: "master",
},
}
if (!process.env.SPACE_ID && !process.env.ENVIRONMENT_ID) {
return {
...config,
properties: {
...config.properties,
...spaceEnvProperties,
},
required: [...(config.required || []), "spaceId", "environmentId"],
}
}
return config
}
// Tool definitions for Entry operations
export const getEntryTools = () => {
return {
SEARCH_ENTRIES: {
name: "search_entries",
description:
"Search for entries using query parameters. Returns a maximum of 3 items per request. Use skip parameter to paginate through results.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
query: {
type: "object",
description: "Query parameters for searching entries",
properties: {
content_type: { type: "string" },
select: { type: "string" },
limit: {
type: "number",
default: 3,
maximum: 3,
description: "Maximum number of items to return (max: 3)",
},
skip: {
type: "number",
default: 0,
description: "Number of items to skip for pagination",
},
order: { type: "string" },
query: { type: "string" },
},
required: ["limit", "skip"],
},
},
required: ["query"],
}),
},
CREATE_ENTRY: {
name: "create_entry",
description:
"Create a new entry in Contentful. Before executing this function, you need to know the contentTypeId (not the content type NAME) and the fields of that contentType. You can get the fields definition by using the GET_CONTENT_TYPE tool. IMPORTANT: All field values MUST include a locale key (e.g., 'en-US') for each value, like: { title: { 'en-US': 'My Title' } }. Every field in Contentful requires a locale even for single-language content.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
contentTypeId: {
type: "string",
description: "The ID of the content type for the new entry",
},
fields: {
type: "object",
description:
"The fields of the entry with localized values. Example: { title: { 'en-US': 'My Title' }, description: { 'en-US': 'My Description' } }",
},
},
required: ["contentTypeId", "fields"],
}),
},
GET_ENTRY: {
name: "get_entry",
description: "Retrieve an existing entry",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: { type: "string" },
},
required: ["entryId"],
}),
},
UPDATE_ENTRY: {
name: "update_entry",
description:
"Update an existing entry. The handler will merge your field updates with the existing entry fields, so you only need to provide the fields and locales you want to change. IMPORTANT: All field values MUST include a locale key (e.g., 'en-US') for each value, like: { title: { 'en-US': 'My Updated Title' } }. Every field in Contentful requires a locale even for single-language content.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: { type: "string" },
fields: {
type: "object",
description:
"The fields to update with localized values. Example: { title: { 'en-US': 'My Updated Title' } }",
},
},
required: ["entryId", "fields"],
}),
},
DELETE_ENTRY: {
name: "delete_entry",
description: "Delete an entry",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: { type: "string" },
},
required: ["entryId"],
}),
},
PUBLISH_ENTRY: {
name: "publish_entry",
description:
"Publish an entry or multiple entries. Accepts either a single entryId (string) or an array of entryIds (up to 100 entries). For a single entry, it uses the standard publish operation. For multiple entries, it automatically uses bulk publishing.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: {
oneOf: [
{ type: "string" },
{
type: "array",
items: { type: "string" },
maxItems: 100,
description: "Array of entry IDs to publish (max: 100)",
},
],
description: "ID of the entry to publish, or an array of entry IDs (max: 100)",
},
},
required: ["entryId"],
}),
},
UNPUBLISH_ENTRY: {
name: "unpublish_entry",
description:
"Unpublish an entry or multiple entries. Accepts either a single entryId (string) or an array of entryIds (up to 100 entries). For a single entry, it uses the standard unpublish operation. For multiple entries, it automatically uses bulk unpublishing.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: {
oneOf: [
{ type: "string" },
{
type: "array",
items: { type: "string" },
maxItems: 100,
description: "Array of entry IDs to unpublish (max: 100)",
},
],
description: "ID of the entry to unpublish, or an array of entry IDs (max: 100)",
},
},
required: ["entryId"],
}),
},
}
}
// Tool definitions for Asset operations
export const getAssetTools = () => {
return {
LIST_ASSETS: {
name: "list_assets",
description:
"List assets in a space. Returns a maximum of 3 items per request. Use skip parameter to paginate through results.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
limit: {
type: "number",
default: 3,
maximum: 3,
description: "Maximum number of items to return (max: 3)",
},
skip: {
type: "number",
default: 0,
description: "Number of items to skip for pagination",
},
},
required: ["limit", "skip"],
}),
},
UPLOAD_ASSET: {
name: "upload_asset",
description: "Upload a new asset",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
file: {
type: "object",
properties: {
upload: { type: "string" },
fileName: { type: "string" },
contentType: { type: "string" },
},
required: ["upload", "fileName", "contentType"],
},
},
required: ["title", "file"],
}),
},
GET_ASSET: {
name: "get_asset",
description: "Retrieve an asset",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
assetId: { type: "string" },
},
required: ["assetId"],
}),
},
UPDATE_ASSET: {
name: "update_asset",
description: "Update an asset",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
assetId: { type: "string" },
title: { type: "string" },
description: { type: "string" },
file: {
type: "object",
properties: {
url: { type: "string" },
fileName: { type: "string" },
contentType: { type: "string" },
},
required: ["url", "fileName", "contentType"],
},
},
required: ["assetId"],
}),
},
DELETE_ASSET: {
name: "delete_asset",
description: "Delete an asset",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
assetId: { type: "string" },
},
required: ["assetId"],
}),
},
PUBLISH_ASSET: {
name: "publish_asset",
description: "Publish an asset",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
assetId: { type: "string" },
},
required: ["assetId"],
}),
},
UNPUBLISH_ASSET: {
name: "unpublish_asset",
description: "Unpublish an asset",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
assetId: { type: "string" },
},
required: ["assetId"],
}),
},
}
}
// Tool definitions for Content Type operations
export const getContentTypeTools = () => {
return {
LIST_CONTENT_TYPES: {
name: "list_content_types",
description:
"List content types in a space. Returns a maximum of 10 items per request. Use skip parameter to paginate through results.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
limit: {
type: "number",
default: 10,
maximum: 20,
description: "Maximum number of items to return (max: 3)",
},
skip: {
type: "number",
default: 0,
description: "Number of items to skip for pagination",
},
},
required: ["limit", "skip"],
}),
},
GET_CONTENT_TYPE: {
name: "get_content_type",
description: "Get details of a specific content type",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
contentTypeId: { type: "string" },
},
required: ["contentTypeId"],
}),
},
CREATE_CONTENT_TYPE: {
name: "create_content_type",
description: "Create a new content type",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
name: { type: "string" },
fields: {
type: "array",
description: "Array of field definitions for the content type",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "The ID of the field",
},
name: {
type: "string",
description: "Display name of the field",
},
type: {
type: "string",
description:
"Type of the field (Text, Number, Date, Location, Media, Boolean, JSON, Link, Array, etc)",
enum: [
"Symbol",
"Text",
"Integer",
"Number",
"Date",
"Location",
"Object",
"Boolean",
"Link",
"Array",
],
},
required: {
type: "boolean",
description: "Whether this field is required",
default: false,
},
localized: {
type: "boolean",
description: "Whether this field can be localized",
default: false,
},
linkType: {
type: "string",
description:
"Required for Link fields. Specifies what type of resource this field links to",
enum: ["Entry", "Asset"],
},
items: {
type: "object",
description:
"Required for Array fields. Specifies the type of items in the array",
properties: {
type: {
type: "string",
enum: ["Symbol", "Link"],
},
linkType: {
type: "string",
enum: ["Entry", "Asset"],
},
validations: {
type: "array",
items: {
type: "object",
},
},
},
},
validations: {
type: "array",
description: "Array of validation rules for the field",
items: {
type: "object",
},
},
},
required: ["id", "name", "type"],
},
},
description: { type: "string" },
displayField: { type: "string" },
},
required: ["name", "fields"],
}),
},
UPDATE_CONTENT_TYPE: {
name: "update_content_type",
description:
"Update an existing content type. The handler will merge your field updates with existing content type data, so you only need to provide the fields and properties you want to change.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
contentTypeId: { type: "string" },
name: { type: "string" },
fields: {
type: "array",
items: { type: "object" },
},
description: { type: "string" },
displayField: { type: "string" },
},
required: ["contentTypeId", "fields"],
}),
},
DELETE_CONTENT_TYPE: {
name: "delete_content_type",
description: "Delete a content type",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
contentTypeId: { type: "string" },
},
required: ["contentTypeId"],
}),
},
PUBLISH_CONTENT_TYPE: {
name: "publish_content_type",
description: "Publish a content type",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
contentTypeId: { type: "string" },
},
required: ["contentTypeId"],
}),
},
}
}
// Tool definitions for Space & Environment operations
export const getSpaceEnvTools = () => {
if (process.env.SPACE_ID && process.env.ENVIRONMENT_ID) {
return {}
}
return {
LIST_SPACES: {
name: "list_spaces",
description: "List all available spaces",
inputSchema: {
type: "object",
properties: {},
},
},
GET_SPACE: {
name: "get_space",
description: "Get details of a space",
inputSchema: {
type: "object",
properties: {
spaceId: { type: "string" },
},
required: ["spaceId"],
},
},
LIST_ENVIRONMENTS: {
name: "list_environments",
description: "List all environments in a space",
inputSchema: {
type: "object",
properties: {
spaceId: { type: "string" },
},
required: ["spaceId"],
},
},
CREATE_ENVIRONMENT: {
name: "create_environment",
description: "Create a new environment",
inputSchema: {
type: "object",
properties: {
spaceId: { type: "string" },
environmentId: { type: "string" },
name: { type: "string" },
},
required: ["spaceId", "environmentId", "name"],
},
},
DELETE_ENVIRONMENT: {
name: "delete_environment",
description: "Delete an environment",
inputSchema: {
type: "object",
properties: {
spaceId: { type: "string" },
environmentId: { type: "string" },
},
required: ["spaceId", "environmentId"],
},
},
}
}
// Tool definitions for Bulk Actions
export const getBulkActionTools = () => {
return {
BULK_VALIDATE: {
name: "bulk_validate",
description: "Validate multiple entries at once",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryIds: {
type: "array",
description: "Array of entry IDs to validate",
items: {
type: "string",
},
},
},
required: ["entryIds"],
}),
},
}
}
// Tool definitions for AI Actions
export const getAiActionTools = () => {
return {
LIST_AI_ACTIONS: {
name: "list_ai_actions",
description: "List all AI Actions in a space",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
limit: {
type: "number",
default: 100,
description: "Maximum number of AI Actions to return",
},
skip: {
type: "number",
default: 0,
description: "Number of AI Actions to skip for pagination",
},
status: {
type: "string",
enum: ["all", "published"],
description: "Filter AI Actions by status",
},
},
required: [],
}),
},
GET_AI_ACTION: {
name: "get_ai_action",
description: "Get a specific AI Action by ID",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
aiActionId: {
type: "string",
description: "The ID of the AI Action to retrieve",
},
},
required: ["aiActionId"],
}),
},
CREATE_AI_ACTION: {
name: "create_ai_action",
description: "Create a new AI Action",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
name: {
type: "string",
description: "The name of the AI Action",
},
description: {
type: "string",
description: "The description of the AI Action",
},
instruction: {
type: "object",
description: "The instruction object containing the template and variables",
properties: {
template: {
type: "string",
description: "The prompt template with variable placeholders",
},
variables: {
type: "array",
description: "Array of variable definitions",
items: {
type: "object",
},
},
conditions: {
type: "array",
description: "Optional array of conditions for the template",
items: {
type: "object",
},
},
},
required: ["template", "variables"],
},
configuration: {
type: "object",
description: "The model configuration",
properties: {
modelType: {
type: "string",
description: "The type of model to use (e.g., gpt-4)",
},
modelTemperature: {
type: "number",
description: "The temperature setting for the model (0.0 to 1.0)",
minimum: 0,
maximum: 1,
},
},
required: ["modelType", "modelTemperature"],
},
testCases: {
type: "array",
description: "Optional array of test cases for the AI Action",
items: {
type: "object",
},
},
},
required: ["name", "description", "instruction", "configuration"],
}),
},
UPDATE_AI_ACTION: {
name: "update_ai_action",
description: "Update an existing AI Action",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
aiActionId: {
type: "string",
description: "The ID of the AI Action to update",
},
name: {
type: "string",
description: "The name of the AI Action",
},
description: {
type: "string",
description: "The description of the AI Action",
},
instruction: {
type: "object",
description: "The instruction object containing the template and variables",
properties: {
template: {
type: "string",
description: "The prompt template with variable placeholders",
},
variables: {
type: "array",
description: "Array of variable definitions",
items: {
type: "object",
},
},
conditions: {
type: "array",
description: "Optional array of conditions for the template",
items: {
type: "object",
},
},
},
required: ["template", "variables"],
},
configuration: {
type: "object",
description: "The model configuration",
properties: {
modelType: {
type: "string",
description: "The type of model to use (e.g., gpt-4)",
},
modelTemperature: {
type: "number",
description: "The temperature setting for the model (0.0 to 1.0)",
minimum: 0,
maximum: 1,
},
},
required: ["modelType", "modelTemperature"],
},
testCases: {
type: "array",
description: "Optional array of test cases for the AI Action",
items: {
type: "object",
},
},
},
required: ["aiActionId", "name", "description", "instruction", "configuration"],
}),
},
DELETE_AI_ACTION: {
name: "delete_ai_action",
description: "Delete an AI Action",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
aiActionId: {
type: "string",
description: "The ID of the AI Action to delete",
},
},
required: ["aiActionId"],
}),
},
PUBLISH_AI_ACTION: {
name: "publish_ai_action",
description: "Publish an AI Action",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
aiActionId: {
type: "string",
description: "The ID of the AI Action to publish",
},
},
required: ["aiActionId"],
}),
},
UNPUBLISH_AI_ACTION: {
name: "unpublish_ai_action",
description: "Unpublish an AI Action",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
aiActionId: {
type: "string",
description: "The ID of the AI Action to unpublish",
},
},
required: ["aiActionId"],
}),
},
INVOKE_AI_ACTION: {
name: "invoke_ai_action",
description: "Invoke an AI Action with variables",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
aiActionId: {
type: "string",
description: "The ID of the AI Action to invoke",
},
variables: {
type: "object",
description: "Key-value pairs of variable IDs and their values",
additionalProperties: {
type: "string",
},
},
rawVariables: {
type: "array",
description:
"Array of raw variable objects (for complex variable types like references)",
items: {
type: "object",
},
},
outputFormat: {
type: "string",
enum: ["Markdown", "RichText", "PlainText"],
default: "Markdown",
description: "The format of the output content",
},
waitForCompletion: {
type: "boolean",
default: true,
description: "Whether to wait for the AI Action to complete before returning",
},
},
required: ["aiActionId"],
}),
},
GET_AI_ACTION_INVOCATION: {
name: "get_ai_action_invocation",
description: "Get the result of a previous AI Action invocation",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
aiActionId: {
type: "string",
description: "The ID of the AI Action",
},
invocationId: {
type: "string",
description: "The ID of the specific invocation to retrieve",
},
},
required: ["aiActionId", "invocationId"],
}),
},
}
}
// Tool definitions for Comment operations
export const getCommentTools = () => {
return {
GET_COMMENTS: {
name: "get_comments",
description:
"Retrieve comments for an entry with pagination support. Returns comments with their status and body content.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: {
type: "string",
description: "The unique identifier of the entry to get comments for",
},
bodyFormat: {
type: "string",
enum: ["plain-text", "rich-text"],
default: "plain-text",
description: "Format for the comment body content",
},
status: {
type: "string",
enum: ["active", "resolved", "all"],
default: "active",
description: "Filter comments by status",
},
limit: {
type: "number",
default: 10,
minimum: 1,
maximum: 100,
description: "Maximum number of comments to return (1-100, default: 10)",
},
skip: {
type: "number",
default: 0,
minimum: 0,
description: "Number of comments to skip for pagination (default: 0)",
},
},
required: ["entryId"],
}),
},
CREATE_COMMENT: {
name: "create_comment",
description:
"Create a new comment on an entry. The comment will be created with the specified body and status. To create a threaded conversation (reply to an existing comment), provide the parent comment ID. This allows you to work around the 512-character limit by creating threaded replies.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: {
type: "string",
description: "The unique identifier of the entry to comment on",
},
body: {
type: "string",
description: "The content of the comment (max 512 characters)",
},
status: {
type: "string",
enum: ["active"],
default: "active",
description: "The status of the comment",
},
parent: {
type: "string",
description:
"Optional ID of the parent comment to reply to. Use this to create threaded conversations or to continue longer messages by replying to your own comments.",
},
},
required: ["entryId", "body"],
}),
},
GET_SINGLE_COMMENT: {
name: "get_single_comment",
description: "Retrieve a specific comment by its ID for an entry.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: {
type: "string",
description: "The unique identifier of the entry",
},
commentId: {
type: "string",
description: "The unique identifier of the comment to retrieve",
},
bodyFormat: {
type: "string",
enum: ["plain-text", "rich-text"],
default: "plain-text",
description: "Format for the comment body content",
},
},
required: ["entryId", "commentId"],
}),
},
DELETE_COMMENT: {
name: "delete_comment",
description: "Delete a specific comment from an entry.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: {
type: "string",
description: "The unique identifier of the entry",
},
commentId: {
type: "string",
description: "The unique identifier of the comment to delete",
},
},
required: ["entryId", "commentId"],
}),
},
UPDATE_COMMENT: {
name: "update_comment",
description:
"Update an existing comment on an entry. The handler will merge your updates with the existing comment data.",
inputSchema: getSpaceEnvProperties({
type: "object",
properties: {
entryId: {
type: "string",
description: "The unique identifier of the entry",
},
commentId: {
type: "string",
description: "The unique identifier of the comment to update",
},
body: {
type: "string",
description: "The updated content of the comment",
},
status: {
type: "string",
enum: ["active", "resolved"],
description: "The updated status of the comment",
},
bodyFormat: {
type: "string",
enum: ["plain-text", "rich-text"],
default: "plain-text",
description: "Format for the comment body content",
},
},
required: ["entryId", "commentId"],
}),
},
}
}
// Export combined tools
export const getTools = () => {
return {
...getEntryTools(),
...getAssetTools(),
...getContentTypeTools(),
...getSpaceEnvTools(),
...getBulkActionTools(),
...getAiActionTools(),
...getCommentTools(),
}
}
```