feat(jsm): add ProForma/JSM Forms discovery tools (#4078)

* feat(jsm): add ProForma/JSM Forms discovery tools

Add three new tools for discovering and inspecting JSM Forms (ProForma) templates
and their structure, enabling dynamic form-based workflows:

- jsm_get_form_templates: List form templates in a project with request type bindings
- jsm_get_form_structure: Get full form design (questions, layout, conditions, sections)
- jsm_get_issue_forms: List forms attached to an issue with submission status

All endpoints validated against the official Atlassian Forms REST API OpenAPI spec.
Uses the Forms Cloud API base URL (jira/forms/cloud/{cloudId}) with X-ExperimentalApi header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(jsm): add input validation and extract shared error parser

- Add validateJiraIssueKey for projectIdOrKey in templates and structure routes
- Add validateJiraCloudId for formId (UUID) in structure route
- Extract parseJsmErrorMessage to shared utils.ts (was duplicated across 3 routes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(jsm): remove unused FORM_QUESTION_PROPERTIES constant

Dead code — the get_form_structure tool passes the raw design object
through as JSON, so this output constant had no consumers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-04-09 13:58:41 -07:00
committed by Waleed Latif
parent 149ad9f36a
commit 7391cd1a49
13 changed files with 1023 additions and 1 deletions

View File

@@ -678,4 +678,84 @@ Get the fields required to create a request of a specific type in Jira Service M
| ↳ `defaultValues` | json | Default values for the field |
| ↳ `jiraSchema` | json | Jira field schema with type, system, custom, customId |
### `jsm_get_form_templates`
List forms (ProForma/JSM Forms) in a Jira project to discover form IDs for request types
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `cloudId` | string | No | Jira Cloud ID for the instance |
| `projectIdOrKey` | string | Yes | Jira project ID or key \(e.g., "10001" or "SD"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `projectIdOrKey` | string | Project ID or key |
| `templates` | array | List of forms in the project |
| ↳ `id` | string | Form template ID \(UUID\) |
| ↳ `name` | string | Form template name |
| ↳ `updated` | string | Last updated timestamp \(ISO 8601\) |
| ↳ `issueCreateIssueTypeIds` | json | Issue type IDs that auto-attach this form on issue create |
| ↳ `issueCreateRequestTypeIds` | json | Request type IDs that auto-attach this form on issue create |
| ↳ `portalRequestTypeIds` | json | Request type IDs that show this form on the customer portal |
| ↳ `recommendedIssueRequestTypeIds` | json | Request type IDs that recommend this form |
| `total` | number | Total number of forms |
### `jsm_get_form_structure`
Get the full structure of a ProForma/JSM form including all questions, field types, choices, layout, and conditions
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `cloudId` | string | No | Jira Cloud ID for the instance |
| `projectIdOrKey` | string | Yes | Jira project ID or key \(e.g., "10001" or "SD"\) |
| `formId` | string | Yes | Form ID \(UUID from Get Form Templates\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `projectIdOrKey` | string | Project ID or key |
| `formId` | string | Form ID |
| `design` | json | Full form design with questions \(field types, labels, choices, validation\), layout \(field ordering\), and conditions |
| `updated` | string | Last updated timestamp |
| `publish` | json | Publishing and request type configuration |
### `jsm_get_issue_forms`
List forms (ProForma/JSM Forms) attached to a Jira issue with metadata (name, submitted status, lock)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `cloudId` | string | No | Jira Cloud ID for the instance |
| `issueIdOrKey` | string | Yes | Issue ID or key \(e.g., "SD-123", "10001"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | Timestamp of the operation |
| `issueIdOrKey` | string | Issue ID or key |
| `forms` | array | List of forms attached to the issue |
| ↳ `id` | string | Form instance ID \(UUID\) |
| ↳ `name` | string | Form name |
| ↳ `updated` | string | Last updated timestamp \(ISO 8601\) |
| ↳ `submitted` | boolean | Whether the form has been submitted |
| ↳ `lock` | boolean | Whether the form is locked |
| ↳ `internal` | boolean | Whether the form is internal-only |
| ↳ `formTemplateId` | string | Source form template ID \(UUID\) |
| `total` | number | Total number of forms |

View File

@@ -6614,9 +6614,21 @@
{
"name": "Get Request Type Fields",
"description": "Get the fields required to create a request of a specific type in Jira Service Management"
},
{
"name": "Get Form Templates",
"description": "List forms (ProForma/JSM Forms) in a Jira project to discover form IDs for request types"
},
{
"name": "Get Form Structure",
"description": "Get the full structure of a ProForma/JSM form including all questions, field types, choices, layout, and conditions"
},
{
"name": "Get Issue Forms",
"description": "List forms (ProForma/JSM Forms) attached to a Jira issue with metadata (name, submitted status, lock)"
}
],
"operationCount": 21,
"operationCount": 24,
"triggers": [],
"triggerCount": 0,
"authType": "oauth",

View File

@@ -0,0 +1,115 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import {
getJiraCloudId,
getJsmFormsApiBaseUrl,
getJsmHeaders,
parseJsmErrorMessage,
} from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmIssueFormsAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey } = body
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!issueIdOrKey) {
logger.error('Missing issueIdOrKey in request')
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
}
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
if (!issueIdOrKeyValidation.isValid) {
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
}
const baseUrl = getJsmFormsApiBaseUrl(cloudId)
const url = `${baseUrl}/issue/${encodeURIComponent(issueIdOrKey)}/form`
logger.info('Fetching issue forms from:', { url, issueIdOrKey })
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM Forms API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: parseJsmErrorMessage(response.status, response.statusText, errorText),
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
const forms = Array.isArray(data) ? data : (data.values ?? data.forms ?? [])
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
forms: forms.map((form: Record<string, unknown>) => ({
id: form.id ?? null,
name: form.name ?? null,
updated: form.updated ?? null,
submitted: form.submitted ?? false,
lock: form.lock ?? false,
internal: form.internal ?? null,
formTemplateId: (form.formTemplate as Record<string, unknown>)?.id ?? null,
})),
total: forms.length,
},
})
} catch (error) {
logger.error('Error fetching issue forms:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,117 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import {
getJiraCloudId,
getJsmFormsApiBaseUrl,
getJsmHeaders,
parseJsmErrorMessage,
} from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmFormStructureAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey, formId } = body
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!projectIdOrKey) {
logger.error('Missing projectIdOrKey in request')
return NextResponse.json({ error: 'Project ID or key is required' }, { status: 400 })
}
if (!formId) {
logger.error('Missing formId in request')
return NextResponse.json({ error: 'Form ID is required' }, { status: 400 })
}
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const projectIdOrKeyValidation = validateJiraIssueKey(projectIdOrKey, 'projectIdOrKey')
if (!projectIdOrKeyValidation.isValid) {
return NextResponse.json({ error: projectIdOrKeyValidation.error }, { status: 400 })
}
const formIdValidation = validateJiraCloudId(formId, 'formId')
if (!formIdValidation.isValid) {
return NextResponse.json({ error: formIdValidation.error }, { status: 400 })
}
const baseUrl = getJsmFormsApiBaseUrl(cloudId)
const url = `${baseUrl}/project/${encodeURIComponent(projectIdOrKey)}/form/${encodeURIComponent(formId)}`
logger.info('Fetching form template from:', { url, projectIdOrKey, formId })
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM Forms API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: parseJsmErrorMessage(response.status, response.statusText, errorText),
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
projectIdOrKey,
formId,
design: data.design ?? null,
updated: data.updated ?? null,
publish: data.publish ?? null,
},
})
} catch (error) {
logger.error('Error fetching form structure:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,115 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import {
getJiraCloudId,
getJsmFormsApiBaseUrl,
getJsmHeaders,
parseJsmErrorMessage,
} from '@/tools/jsm/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('JsmFormTemplatesAPI')
export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, projectIdOrKey } = body
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!projectIdOrKey) {
logger.error('Missing projectIdOrKey in request')
return NextResponse.json({ error: 'Project ID or key is required' }, { status: 400 })
}
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const projectIdOrKeyValidation = validateJiraIssueKey(projectIdOrKey, 'projectIdOrKey')
if (!projectIdOrKeyValidation.isValid) {
return NextResponse.json({ error: projectIdOrKeyValidation.error }, { status: 400 })
}
const baseUrl = getJsmFormsApiBaseUrl(cloudId)
const url = `${baseUrl}/project/${encodeURIComponent(projectIdOrKey)}/form`
logger.info('Fetching project form templates from:', { url, projectIdOrKey })
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM Forms API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{
error: parseJsmErrorMessage(response.status, response.statusText, errorText),
details: errorText,
},
{ status: response.status }
)
}
const data = await response.json()
const templates = Array.isArray(data) ? data : (data.values ?? [])
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
projectIdOrKey,
templates: templates.map((template: Record<string, unknown>) => ({
id: template.id ?? null,
name: template.name ?? null,
updated: template.updated ?? null,
issueCreateIssueTypeIds: template.issueCreateIssueTypeIds ?? [],
issueCreateRequestTypeIds: template.issueCreateRequestTypeIds ?? [],
portalRequestTypeIds: template.portalRequestTypeIds ?? [],
recommendedIssueRequestTypeIds: template.recommendedIssueRequestTypeIds ?? [],
})),
total: templates.length,
},
})
} catch (error) {
logger.error('Error fetching form templates:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -44,6 +44,9 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
{ label: 'Get Approvals', id: 'get_approvals' },
{ label: 'Answer Approval', id: 'answer_approval' },
{ label: 'Get Request Type Fields', id: 'get_request_type_fields' },
{ label: 'Get Form Templates', id: 'get_form_templates' },
{ label: 'Get Form Structure', id: 'get_form_structure' },
{ label: 'Get Issue Forms', id: 'get_issue_forms' },
],
value: () => 'get_service_desks',
},
@@ -191,9 +194,26 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
'add_participants',
'get_approvals',
'answer_approval',
'get_issue_forms',
],
},
},
{
id: 'projectIdOrKey',
title: 'Project ID or Key',
type: 'short-input',
required: { field: 'operation', value: ['get_form_templates', 'get_form_structure'] },
placeholder: 'Enter Jira project ID or key (e.g., 10001 or SD)',
condition: { field: 'operation', value: ['get_form_templates', 'get_form_structure'] },
},
{
id: 'formId',
title: 'Form ID',
type: 'short-input',
required: true,
placeholder: 'Enter form ID (UUID from Get Form Templates)',
condition: { field: 'operation', value: 'get_form_structure' },
},
{
id: 'summary',
title: 'Summary',
@@ -503,6 +523,9 @@ Return ONLY the comment text - no explanations.`,
'jsm_get_approvals',
'jsm_answer_approval',
'jsm_get_request_type_fields',
'jsm_get_form_templates',
'jsm_get_form_structure',
'jsm_get_issue_forms',
],
config: {
tool: (params) => {
@@ -549,6 +572,12 @@ Return ONLY the comment text - no explanations.`,
return 'jsm_answer_approval'
case 'get_request_type_fields':
return 'jsm_get_request_type_fields'
case 'get_form_templates':
return 'jsm_get_form_templates'
case 'get_form_structure':
return 'jsm_get_form_structure'
case 'get_issue_forms':
return 'jsm_get_issue_forms'
default:
return 'jsm_get_service_desks'
}
@@ -808,6 +837,34 @@ Return ONLY the comment text - no explanations.`,
serviceDeskId: params.serviceDeskId,
requestTypeId: params.requestTypeId,
}
case 'get_form_templates':
if (!params.projectIdOrKey) {
throw new Error('Project ID or key is required')
}
return {
...baseParams,
projectIdOrKey: params.projectIdOrKey,
}
case 'get_form_structure':
if (!params.projectIdOrKey) {
throw new Error('Project ID or key is required')
}
if (!params.formId) {
throw new Error('Form ID is required')
}
return {
...baseParams,
projectIdOrKey: params.projectIdOrKey,
formId: params.formId,
}
case 'get_issue_forms':
if (!params.issueIdOrKey) {
throw new Error('Issue ID or key is required')
}
return {
...baseParams,
issueIdOrKey: params.issueIdOrKey,
}
default:
return baseParams
}
@@ -857,6 +914,8 @@ Return ONLY the comment text - no explanations.`,
type: 'string',
description: 'JSON object of form answers for form-based request types',
},
projectIdOrKey: { type: 'string', description: 'Jira project ID or key' },
formId: { type: 'string', description: 'Form ID (UUID)' },
searchQuery: { type: 'string', description: 'Filter request types by name' },
groupId: { type: 'string', description: 'Filter by request type group ID' },
expand: { type: 'string', description: 'Comma-separated fields to expand' },
@@ -899,5 +958,25 @@ Return ONLY the comment text - no explanations.`,
type: 'boolean',
description: 'Whether requests can be raised on behalf of another user',
},
templates: {
type: 'json',
description:
'Array of form templates (id, name, updated, portalRequestTypeIds, issueCreateIssueTypeIds)',
},
design: {
type: 'json',
description:
'Full form design with questions (labels, types, choices, validation), layout, conditions, sections, settings',
},
publish: {
type: 'json',
description: 'Form publishing and request type configuration',
},
updated: { type: 'string', description: 'Last updated timestamp' },
forms: {
type: 'json',
description:
'Array of forms attached to an issue (id, name, updated, submitted, lock, internal, formTemplateId)',
},
},
}

View File

@@ -0,0 +1,121 @@
import type { JsmGetFormStructureParams, JsmGetFormStructureResponse } from '@/tools/jsm/types'
import type { ToolConfig } from '@/tools/types'
export const jsmGetFormStructureTool: ToolConfig<
JsmGetFormStructureParams,
JsmGetFormStructureResponse
> = {
id: 'jsm_get_form_structure',
name: 'JSM Get Form Structure',
description:
'Get the full structure of a ProForma/JSM form including all questions, field types, choices, layout, and conditions',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira Service Management',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'Jira Cloud ID for the instance',
},
projectIdOrKey: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Jira project ID or key (e.g., "10001" or "SD")',
},
formId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Form ID (UUID from Get Form Templates)',
},
},
request: {
url: '/api/tools/jsm/forms/structure',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
projectIdOrKey: params.projectIdOrKey,
formId: params.formId,
}),
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
if (!responseText) {
return {
success: false,
output: {
ts: new Date().toISOString(),
projectIdOrKey: '',
formId: '',
design: null,
updated: null,
publish: null,
},
error: 'Empty response from API',
}
}
const data = JSON.parse(responseText)
if (data.success && data.output) {
return data
}
return {
success: data.success || false,
output: data.output || {
ts: new Date().toISOString(),
projectIdOrKey: '',
formId: '',
design: null,
updated: null,
publish: null,
},
error: data.error,
}
},
outputs: {
ts: { type: 'string', description: 'Timestamp of the operation' },
projectIdOrKey: { type: 'string', description: 'Project ID or key' },
formId: { type: 'string', description: 'Form ID' },
design: {
type: 'json',
description:
'Full form design with questions (field types, labels, choices, validation), layout (field ordering), and conditions',
},
updated: { type: 'string', description: 'Last updated timestamp', optional: true },
publish: {
type: 'json',
description: 'Publishing and request type configuration',
optional: true,
},
},
}

View File

@@ -0,0 +1,108 @@
import type { JsmGetFormTemplatesParams, JsmGetFormTemplatesResponse } from '@/tools/jsm/types'
import { FORM_TEMPLATE_PROPERTIES } from '@/tools/jsm/types'
import type { ToolConfig } from '@/tools/types'
export const jsmGetFormTemplatesTool: ToolConfig<
JsmGetFormTemplatesParams,
JsmGetFormTemplatesResponse
> = {
id: 'jsm_get_form_templates',
name: 'JSM Get Form Templates',
description:
'List forms (ProForma/JSM Forms) in a Jira project to discover form IDs for request types',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira Service Management',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'Jira Cloud ID for the instance',
},
projectIdOrKey: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Jira project ID or key (e.g., "10001" or "SD")',
},
},
request: {
url: '/api/tools/jsm/forms/templates',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
projectIdOrKey: params.projectIdOrKey,
}),
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
if (!responseText) {
return {
success: false,
output: {
ts: new Date().toISOString(),
projectIdOrKey: '',
templates: [],
total: 0,
},
error: 'Empty response from API',
}
}
const data = JSON.parse(responseText)
if (data.success && data.output) {
return data
}
return {
success: data.success || false,
output: data.output || {
ts: new Date().toISOString(),
projectIdOrKey: '',
templates: [],
total: 0,
},
error: data.error,
}
},
outputs: {
ts: { type: 'string', description: 'Timestamp of the operation' },
projectIdOrKey: { type: 'string', description: 'Project ID or key' },
templates: {
type: 'array',
description: 'List of forms in the project',
items: {
type: 'object',
properties: FORM_TEMPLATE_PROPERTIES,
},
},
total: { type: 'number', description: 'Total number of forms' },
},
}

View File

@@ -0,0 +1,105 @@
import type { JsmGetIssueFormsParams, JsmGetIssueFormsResponse } from '@/tools/jsm/types'
import { ISSUE_FORM_PROPERTIES } from '@/tools/jsm/types'
import type { ToolConfig } from '@/tools/types'
export const jsmGetIssueFormsTool: ToolConfig<JsmGetIssueFormsParams, JsmGetIssueFormsResponse> = {
id: 'jsm_get_issue_forms',
name: 'JSM Get Issue Forms',
description:
'List forms (ProForma/JSM Forms) attached to a Jira issue with metadata (name, submitted status, lock)',
version: '1.0.0',
oauth: {
required: true,
provider: 'jira',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth access token for Jira Service Management',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
},
cloudId: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'Jira Cloud ID for the instance',
},
issueIdOrKey: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Issue ID or key (e.g., "SD-123", "10001")',
},
},
request: {
url: '/api/tools/jsm/forms/issue',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
domain: params.domain,
accessToken: params.accessToken,
cloudId: params.cloudId,
issueIdOrKey: params.issueIdOrKey,
}),
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
if (!responseText) {
return {
success: false,
output: {
ts: new Date().toISOString(),
issueIdOrKey: '',
forms: [],
total: 0,
},
error: 'Empty response from API',
}
}
const data = JSON.parse(responseText)
if (data.success && data.output) {
return data
}
return {
success: data.success || false,
output: data.output || {
ts: new Date().toISOString(),
issueIdOrKey: '',
forms: [],
total: 0,
},
error: data.error,
}
},
outputs: {
ts: { type: 'string', description: 'Timestamp of the operation' },
issueIdOrKey: { type: 'string', description: 'Issue ID or key' },
forms: {
type: 'array',
description: 'List of forms attached to the issue',
items: {
type: 'object',
properties: ISSUE_FORM_PROPERTIES,
},
},
total: { type: 'number', description: 'Total number of forms' },
},
}

View File

@@ -8,6 +8,9 @@ import { jsmCreateRequestTool } from '@/tools/jsm/create_request'
import { jsmGetApprovalsTool } from '@/tools/jsm/get_approvals'
import { jsmGetCommentsTool } from '@/tools/jsm/get_comments'
import { jsmGetCustomersTool } from '@/tools/jsm/get_customers'
import { jsmGetFormStructureTool } from '@/tools/jsm/get_form_structure'
import { jsmGetFormTemplatesTool } from '@/tools/jsm/get_form_templates'
import { jsmGetIssueFormsTool } from '@/tools/jsm/get_issue_forms'
import { jsmGetOrganizationsTool } from '@/tools/jsm/get_organizations'
import { jsmGetParticipantsTool } from '@/tools/jsm/get_participants'
import { jsmGetQueuesTool } from '@/tools/jsm/get_queues'
@@ -31,6 +34,9 @@ export {
jsmGetApprovalsTool,
jsmGetCommentsTool,
jsmGetCustomersTool,
jsmGetFormStructureTool,
jsmGetFormTemplatesTool,
jsmGetIssueFormsTool,
jsmGetOrganizationsTool,
jsmGetParticipantsTool,
jsmGetQueuesTool,

View File

@@ -222,6 +222,44 @@ export const REQUEST_TYPE_FIELD_PROPERTIES = {
},
} as const
/** Output properties for a FormTemplateIndexEntry (list endpoint) per OpenAPI spec */
export const FORM_TEMPLATE_PROPERTIES = {
id: { type: 'string', description: 'Form template ID (UUID)' },
name: { type: 'string', description: 'Form template name' },
updated: { type: 'string', description: 'Last updated timestamp (ISO 8601)' },
issueCreateIssueTypeIds: {
type: 'json',
description: 'Issue type IDs that auto-attach this form on issue create',
},
issueCreateRequestTypeIds: {
type: 'json',
description: 'Request type IDs that auto-attach this form on issue create',
},
portalRequestTypeIds: {
type: 'json',
description: 'Request type IDs that show this form on the customer portal',
},
recommendedIssueRequestTypeIds: {
type: 'json',
description: 'Request type IDs that recommend this form',
},
} as const
/** Output properties for a FormIndexEntry (issue forms list endpoint) per OpenAPI spec */
export const ISSUE_FORM_PROPERTIES = {
id: { type: 'string', description: 'Form instance ID (UUID)' },
name: { type: 'string', description: 'Form name' },
updated: { type: 'string', description: 'Last updated timestamp (ISO 8601)' },
submitted: { type: 'boolean', description: 'Whether the form has been submitted' },
lock: { type: 'boolean', description: 'Whether the form is locked' },
internal: { type: 'boolean', description: 'Whether the form is internal-only', optional: true },
formTemplateId: {
type: 'string',
description: 'Source form template ID (UUID)',
optional: true,
},
} as const
// ---------------------------------------------------------------------------
// Data model interfaces
// ---------------------------------------------------------------------------
@@ -778,6 +816,89 @@ export interface JsmGetRequestTypeFieldsResponse extends ToolResponse {
}
}
export interface JsmGetFormTemplatesParams extends JsmBaseParams {
projectIdOrKey: string
}
export interface JsmGetFormStructureParams extends JsmBaseParams {
projectIdOrKey: string
formId: string
}
export interface JsmGetIssueFormsParams extends JsmBaseParams {
issueIdOrKey: string
}
/** FormQuestion per OpenAPI spec */
export interface JsmFormQuestion {
label: string
type: string
validation: { rq?: boolean; [key: string]: unknown }
choices?: Array<{ id: string; label: string; other?: boolean }>
dcId?: string
defaultAnswer?: Record<string, unknown>
description?: string
jiraField?: string
questionKey?: string
}
/** FormTemplateIndexEntry per OpenAPI spec */
export interface JsmFormTemplate {
id: string
name: string
updated: string
issueCreateIssueTypeIds: number[]
issueCreateRequestTypeIds: number[]
portalRequestTypeIds: number[]
recommendedIssueRequestTypeIds: number[]
}
/** FormIndexEntry (issue form) per OpenAPI spec */
export interface JsmIssueForm {
id: string
name: string
updated: string
submitted: boolean
lock: boolean
internal?: boolean
formTemplateId?: string
}
export interface JsmGetFormTemplatesResponse extends ToolResponse {
output: {
ts: string
projectIdOrKey: string
templates: JsmFormTemplate[]
total: number
}
}
export interface JsmGetFormStructureResponse extends ToolResponse {
output: {
ts: string
projectIdOrKey: string
formId: string
design: {
questions: Record<string, JsmFormQuestion>
layout: unknown[]
conditions: Record<string, unknown>
sections: Record<string, unknown>
settings: { name: string; submit: { lock: boolean; pdf: boolean }; language?: string }
} | null
updated: string | null
publish: Record<string, unknown> | null
}
}
export interface JsmGetIssueFormsResponse extends ToolResponse {
output: {
ts: string
issueIdOrKey: string
forms: JsmIssueForm[]
total: number
}
}
// ---------------------------------------------------------------------------
// Union type for all JSM responses
// ---------------------------------------------------------------------------
@@ -805,3 +926,6 @@ export type JsmResponse =
| JsmGetApprovalsResponse
| JsmAnswerApprovalResponse
| JsmGetRequestTypeFieldsResponse
| JsmGetFormTemplatesResponse
| JsmGetFormStructureResponse
| JsmGetIssueFormsResponse

View File

@@ -13,6 +13,15 @@ export function getJsmApiBaseUrl(cloudId: string): string {
return `https://api.atlassian.com/ex/jira/${cloudId}/rest/servicedeskapi`
}
/**
* Build the base URL for JSM Forms (ProForma) API
* @param cloudId - The Jira Cloud ID
* @returns The base URL for the JSM Forms API
*/
export function getJsmFormsApiBaseUrl(cloudId: string): string {
return `https://api.atlassian.com/jira/forms/cloud/${cloudId}`
}
/**
* Build common headers for JSM API requests
* @param accessToken - The OAuth access token
@@ -26,3 +35,28 @@ export function getJsmHeaders(accessToken: string): Record<string, string> {
'X-ExperimentalApi': 'opt-in',
}
}
/**
* Parse error messages from JSM/Forms API responses
* @param status - HTTP status code
* @param statusText - HTTP status text
* @param errorText - Raw error response body
* @returns Formatted error message string
*/
export function parseJsmErrorMessage(
status: number,
statusText: string,
errorText: string
): string {
try {
const errorData = JSON.parse(errorText)
if (errorData.errorMessage) {
return `JSM Forms API error: ${errorData.errorMessage}`
}
} catch {
if (errorText) {
return `JSM Forms API error: ${errorText}`
}
}
return `JSM Forms API error: ${status} ${statusText}`
}

View File

@@ -1292,6 +1292,9 @@ import {
jsmGetApprovalsTool,
jsmGetCommentsTool,
jsmGetCustomersTool,
jsmGetFormStructureTool,
jsmGetFormTemplatesTool,
jsmGetIssueFormsTool,
jsmGetOrganizationsTool,
jsmGetParticipantsTool,
jsmGetQueuesTool,
@@ -3093,6 +3096,9 @@ export const tools: Record<string, ToolConfig> = {
jsm_add_participants: jsmAddParticipantsTool,
jsm_get_approvals: jsmGetApprovalsTool,
jsm_answer_approval: jsmAnswerApprovalTool,
jsm_get_form_templates: jsmGetFormTemplatesTool,
jsm_get_form_structure: jsmGetFormStructureTool,
jsm_get_issue_forms: jsmGetIssueFormsTool,
kalshi_get_markets: kalshiGetMarketsTool,
kalshi_get_markets_v2: kalshiGetMarketsV2Tool,
kalshi_get_market: kalshiGetMarketTool,