Skip to content

Commit cc599a4

Browse files
waleedlatif1claude
andauthored
feat(manageengine): add ManageEngine ServiceDesk Plus Cloud integration (#7447)
* feat(manageengine): add ManageEngine ServiceDesk Plus Cloud integration 31 tools across requests, problems, changes, assets, and knowledge base solutions, with full create/read/list/update/delete on each plus notes on requests, problems, and changes. Auth reuses the existing Zoho OAuth client: ServiceDesk Plus authenticates through Zoho and scopes are chosen per authorization request rather than per registered client, so manageengine-sdp aliases onto the zoho-desk deployment capability and needs no new env pair. Connecting requires a Zoho account in the US data center, matching the existing Zoho connector's limitation. The standalone Tasks module is deliberately not implemented: its endpoints are documented but the scope table publishes no tasks entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HbUGaP3XyrSm3Lk3k959j2 * fix(manageengine): address review findings - display_id on problems, changes and solutions reused the timestamp property map, so generated metadata described a record number like "140" as epoch milliseconds. Give it its own map. - parseSdpResponse turned a non-empty, non-JSON 2xx body into {} and reported success — a proxy or login page answering 200 with HTML would make a read look empty and a delete look like it worked. Throw instead; an empty body is still tolerated. - Scope the docs setup step: connect a Zoho account, with client registration called out as self-hosted only. - The block described notes on "any ITSM record"; only requests, problems and changes have note endpoints here. - Assert all ten data-center hosts, not four, and guard the count so a new code fails the test until asserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HbUGaP3XyrSm3Lk3k959j2 * fix(manageengine): reject JSON-array response bodies A 2xx body that parses to an array passed the `typeof 'object'` check, so it was accepted as a v3 envelope. It carries no `response_status`, so a list read as empty and a delete reported success. Reject arrays and `null` alongside the scalar case already handled. Also name the display_id children in its description — the docs generator renders one level of output nesting repo-wide, so the shape is otherwise invisible on the page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HbUGaP3XyrSm3Lk3k959j2 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 68f740d commit cc599a4

60 files changed

Lines changed: 8085 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3196,6 +3196,52 @@ export function LemlistIcon(props: SVGProps<SVGSVGElement>) {
31963196
)
31973197
}
31983198

3199+
/**
3200+
* The four brand fills are hardcoded rather than `currentColor`: this is a
3201+
* multi-color mark, so it stays legible on the white tile and bare on a neutral
3202+
* page in both themes. No `iconColor` is set for the same reason — there is no
3203+
* single brand tint to adopt.
3204+
*
3205+
* The source SVG carried its fills through `.st0`–`.st3` CSS classes; those are
3206+
* inlined here so the mark cannot depend on (or leak) global styles.
3207+
*/
3208+
export function ManageEngineIcon(props: SVGProps<SVGSVGElement>) {
3209+
return (
3210+
<svg
3211+
{...props}
3212+
viewBox='0 0 120.2 118.8'
3213+
fill='none'
3214+
xmlns='http://www.w3.org/2000/svg'
3215+
aria-hidden='true'
3216+
>
3217+
<path
3218+
fillRule='evenodd'
3219+
clipRule='evenodd'
3220+
fill='#FFCD22'
3221+
d='M83.1,3.9c46.2,32.2,27,106.8-22.5,114.9l0,0C124.1,118.8,144.8,32.7,83.1,3.9'
3222+
/>
3223+
<path
3224+
fillRule='evenodd'
3225+
clipRule='evenodd'
3226+
fill='#0078B6'
3227+
d='M33.9,3.4c55.2-8.1,77.8,60,53.6,91.5C127.5,54.7,95.5-16.4,33.9,3.4'
3228+
/>
3229+
<path
3230+
fillRule='evenodd'
3231+
clipRule='evenodd'
3232+
fill='#009A50'
3233+
d='M1.3,36.7C28.5-3.6,85,21.3,90.7,62C97.2,11.7,30.8-16.2,1.3,36.7'
3234+
/>
3235+
<path
3236+
fillRule='evenodd'
3237+
clipRule='evenodd'
3238+
fill='#CA2031'
3239+
d='M0,66.8C6.6,25,61,20.6,80.6,49.9C64.6,8.3,1.1,13.4,0,66.8'
3240+
/>
3241+
</svg>
3242+
)
3243+
}
3244+
31993245
export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
32003246
return (
32013247
<svg

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ import {
150150
MailchimpIcon,
151151
MailgunIcon,
152152
MailServerIcon,
153+
ManageEngineIcon,
153154
Mem0Icon,
154155
MicrosoftDataverseIcon,
155156
MicrosoftExcelIcon,
@@ -448,6 +449,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
448449
mailchimp: MailchimpIcon,
449450
mailgun: MailgunIcon,
450451
managed_agent: ClaudeIcon,
452+
manageengine_sdp: ManageEngineIcon,
451453
mem0: Mem0Icon,
452454
memory: BrainIcon,
453455
microsoft_ad: AzureIcon,

apps/docs/content/docs/integrations/manageengine_sdp.mdx

Lines changed: 1396 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
157157
"mailchimp",
158158
"mailgun",
159159
"managed_agent",
160+
"manageengine_sdp",
160161
"mem0",
161162
"memory",
162163
"microsoft_ad",
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { ManageEngineSdpBlock } from '@/blocks/blocks/manageengine-sdp'
6+
import {
7+
buildSdpChangeEntity,
8+
buildSdpSolutionEntity,
9+
} from '@/tools/manageengine_sdp/entity-builders'
10+
11+
type Params = Record<string, unknown>
12+
13+
const mapParams = (params: Params): Params =>
14+
ManageEngineSdpBlock.tools.config?.params?.(params as never) as Params
15+
16+
const selectTool = (params: Params): string =>
17+
ManageEngineSdpBlock.tools.config?.tool?.(params as never) as string
18+
19+
describe('ManageEngine SDP tool selection', () => {
20+
it('maps every operation option to a tool the block declares access to', () => {
21+
const operation = ManageEngineSdpBlock.subBlocks.find((s) => s.id === 'operation') as {
22+
options: { id: string }[]
23+
}
24+
const access = new Set(ManageEngineSdpBlock.tools.access)
25+
for (const { id } of operation.options) {
26+
expect(access.has(selectTool({ operation: id }))).toBe(true)
27+
}
28+
// Every declared tool is reachable — no dead entries in tools.access.
29+
const reachable = new Set(operation.options.map(({ id }) => selectTool({ operation: id })))
30+
expect([...access].filter((id) => !reachable.has(id))).toEqual([])
31+
})
32+
})
33+
34+
describe('ManageEngine SDP cross-operation scoping', () => {
35+
it("never sends another module's record id", () => {
36+
// Every id field populated, as they would be after switching operations.
37+
const stale = {
38+
requestId: 'r1',
39+
problemId: 'p1',
40+
changeId: 'c1',
41+
assetId: 'a1',
42+
solutionId: 's1',
43+
}
44+
const mapped = mapParams({ operation: 'get_request', ...stale })
45+
expect(mapped.requestId).toBe('r1')
46+
expect(mapped.problemId).toBeUndefined()
47+
expect(mapped.changeId).toBeUndefined()
48+
expect(mapped.assetId).toBeUndefined()
49+
expect(mapped.solutionId).toBeUndefined()
50+
})
51+
52+
it("routes each module's title into the shared `title` param and clears the rest", () => {
53+
const titles = { problemTitle: 'P', changeTitle: 'C', solutionTitle: 'S' }
54+
expect(mapParams({ operation: 'create_problem', ...titles }).title).toBe('P')
55+
expect(mapParams({ operation: 'create_change', ...titles }).title).toBe('C')
56+
expect(mapParams({ operation: 'create_solution', ...titles }).title).toBe('S')
57+
// A request write uses `subject`, so `title` must not leak into it.
58+
expect(
59+
mapParams({ operation: 'create_request', subject: 'R', ...titles }).title
60+
).toBeUndefined()
61+
})
62+
63+
it('does not carry a stale request status onto a change write', () => {
64+
const mapped = mapParams({
65+
operation: 'create_change',
66+
status: 'Resolved',
67+
changeStatus: 'Open',
68+
})
69+
expect(mapped.status).toBe('Open')
70+
})
71+
})
72+
73+
/**
74+
* Regression: a switch cannot distinguish "untouched" from "explicitly off", so
75+
* using one on an edit operation silently overwrites server state the user never
76+
* intended to change. ServiceDesk Plus documents no default for either flag, so
77+
* the create side still sends its value explicitly.
78+
*/
79+
describe('ManageEngine SDP edit-safe booleans', () => {
80+
it('omits `emergency` when an update leaves the tri-state unchanged', () => {
81+
const mapped = mapParams({
82+
operation: 'update_change',
83+
changeId: '99',
84+
scheduledStartTime: '2026-09-10T09:00:00.000Z',
85+
updateEmergency: '',
86+
})
87+
expect(mapped.emergency).toBeUndefined()
88+
expect(buildSdpChangeEntity({ accessToken: 't', ...mapped } as never)).not.toHaveProperty(
89+
'emergency'
90+
)
91+
})
92+
93+
it('sends `emergency` on an update only when explicitly chosen', () => {
94+
expect(
95+
mapParams({ operation: 'update_change', changeId: '99', updateEmergency: 'true' }).emergency
96+
).toBe(true)
97+
expect(
98+
mapParams({ operation: 'update_change', changeId: '99', updateEmergency: 'false' }).emergency
99+
).toBe(false)
100+
})
101+
102+
it('sends the switch value explicitly on create, where there is no prior state', () => {
103+
const mapped = mapParams({ operation: 'create_change', changeTitle: 'T', emergency: false })
104+
expect(mapped.emergency).toBe(false)
105+
expect(buildSdpChangeEntity({ accessToken: 't', ...mapped } as never)).toHaveProperty(
106+
'emergency',
107+
false
108+
)
109+
})
110+
111+
it('omits `is_public` when an update leaves the tri-state unchanged', () => {
112+
const mapped = mapParams({
113+
operation: 'update_solution',
114+
solutionId: '55',
115+
solutionTitle: 'New title',
116+
updateSolutionIsPublic: '',
117+
})
118+
expect(mapped.isPublic).toBeUndefined()
119+
expect(buildSdpSolutionEntity({ accessToken: 't', ...mapped } as never)).not.toHaveProperty(
120+
'is_public'
121+
)
122+
})
123+
124+
it('sends `is_public` on an update only when explicitly chosen', () => {
125+
expect(
126+
mapParams({
127+
operation: 'update_solution',
128+
solutionId: '55',
129+
updateSolutionIsPublic: 'false',
130+
}).isPublic
131+
).toBe(false)
132+
})
133+
})

0 commit comments

Comments
 (0)