diff --git a/README.md b/README.md index 8030411..a1b9972 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Add the following configuration to your Claude Desktop configuration file: "env": { "JUPITERONE_API_KEY": "your-api-key-here", "JUPITERONE_ACCOUNT_ID": "your-account-id-here", - "JUPITERONE_BASE_URL": "https://graphql.us.jupiterone.io" + "JUPITERONE_GRAPHQL_URL": "https://graphql.us.jupiterone.io" } } } @@ -54,7 +54,7 @@ Then add this configuration to your Claude Desktop config file: "env": { "JUPITERONE_API_KEY": "your-api-key-here", "JUPITERONE_ACCOUNT_ID": "your-account-id-here", - "JUPITERONE_BASE_URL": "https://graphql.us.jupiterone.io" + "JUPITERONE_GRAPHQL_URL": "https://graphql.us.jupiterone.io" } } } @@ -79,7 +79,7 @@ Replace the placeholder values with your actual JupiterOne credentials: - **JUPITERONE_API_KEY**: Your JupiterOne API key (required) - **JUPITERONE_ACCOUNT_ID**: Your JupiterOne account ID (required). -- **JUPITERONE_BASE_URL**: JupiterOne GraphQL endpoint (optional, defaults to `https://graphql.us.jupiterone.io`) +- **JUPITERONE_GRAPHQL_URL**: JupiterOne GraphQL endpoint (optional, defaults to `https://graphql.us.jupiterone.io`) ### Getting Your JupiterOne Credentials diff --git a/example.env b/example.env index 18228d7..c1a72bc 100644 --- a/example.env +++ b/example.env @@ -1,7 +1,7 @@ # JupiterOne API Configuration JUPITERONE_API_KEY=your-api-key JUPITERONE_ACCOUNT_ID=j1dev -JUPITERONE_BASE_URL=https://graphql.dev.jupiterone.io +JUPITERONE_GRAPHQL_URL=https://graphql.dev.jupiterone.io # Optional: Enable debug logging # DEBUG=jupiterone-mcp* \ No newline at end of file diff --git a/package.json b/package.json index a363f6f..f681cef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jupiterone/jupiterone-mcp", - "version": "0.0.12", + "version": "0.0.13", "description": "Model Context Protocol server for JupiterOne account rules and rule details", "main": "dist/index.js", "bin": { diff --git a/src/__tests__/schemas.test.ts b/src/__tests__/schemas.test.ts new file mode 100644 index 0000000..8e1557f --- /dev/null +++ b/src/__tests__/schemas.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from '@jest/globals'; +import { + CreateInsightsWidgetInputSchema, + RuleTemplatesSchema, + QueryVariablesSchema, + QueryFlagsSchema, + ActionTargetValueSchema, + ActionDataSchema, + JiraAdditionalFieldsSchema, +} from '../server/schemas'; + +describe('Schema Validation Tests', () => { + describe('CreateInsightsWidgetInputSchema', () => { + test('should validate a valid widget input', () => { + const validInput = { + title: 'Test Widget', + type: 'pie', + config: { + queries: [ + { + query: 'FIND DataStore', + name: 'Query 1', + }, + ], + settings: { + pie: { + upwardTrendIsGood: true, + }, + }, + }, + }; + + const result = CreateInsightsWidgetInputSchema.safeParse(validInput); + expect(result.success).toBe(true); + }); + + test('should reject invalid chart type', () => { + const invalidInput = { + title: 'Test Widget', + type: 'invalid-type', + config: { + queries: [], + }, + }; + + const result = CreateInsightsWidgetInputSchema.safeParse(invalidInput); + expect(result.success).toBe(false); + }); + }); + + describe('RuleTemplatesSchema', () => { + test('should validate various template types', () => { + const validTemplates = { + stringVar: 'test', + numberVar: 123, + boolVar: true, + arrayVar: [1, 2, 3], + objectVar: { key: 'value' }, + }; + + const result = RuleTemplatesSchema.safeParse(validTemplates); + expect(result.success).toBe(true); + }); + }); + + describe('QueryVariablesSchema', () => { + test('should validate query variables including null', () => { + const validVariables = { + name: 'John', + age: 30, + isActive: true, + optional: null, + tags: ['tag1', 'tag2'], + metadata: { key: 'value' }, + }; + + const result = QueryVariablesSchema.safeParse(validVariables); + expect(result.success).toBe(true); + }); + }); + + describe('QueryFlagsSchema', () => { + test('should validate query flags', () => { + const validFlags = { + includeDeleted: true, + deferredResponse: 'FORCE', + returnRowMetadata: false, + customFlag: 'custom-value', // passthrough allows this + }; + + const result = QueryFlagsSchema.safeParse(validFlags); + expect(result.success).toBe(true); + }); + + test('should reject invalid deferredResponse value', () => { + const invalidFlags = { + deferredResponse: 'INVALID', + }; + + const result = QueryFlagsSchema.safeParse(invalidFlags); + expect(result.success).toBe(false); + }); + }); + + describe('ActionTargetValueSchema', () => { + test('should validate various target value types', () => { + const testCases = [ + 'string-value', + 123, + true, + ['array', 'values'], + { key: 'object-value' }, + ]; + + testCases.forEach((value) => { + const result = ActionTargetValueSchema.safeParse(value); + expect(result.success).toBe(true); + }); + }); + }); + + describe('ActionDataSchema', () => { + test('should validate string data', () => { + const result = ActionDataSchema.safeParse('simple string'); + expect(result.success).toBe(true); + }); + + test('should validate structured data object', () => { + const structuredData = { + description: 'Test description', + title: 'Test title', + content: { nested: 'data' }, + }; + + const result = ActionDataSchema.safeParse(structuredData); + expect(result.success).toBe(true); + }); + }); + + describe('JiraAdditionalFieldsSchema', () => { + test('should validate Jira fields with string description', () => { + const fields = { + description: 'Simple description', + priority: 'High', + labels: ['bug', 'urgent'], + components: ['backend'], + customField: 'custom-value', + }; + + const result = JiraAdditionalFieldsSchema.safeParse(fields); + expect(result.success).toBe(true); + }); + + test('should validate Jira fields with document description', () => { + const fields = { + description: { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Formatted description', + }, + ], + }, + ], + }, + }; + + const result = JiraAdditionalFieldsSchema.safeParse(fields); + expect(result.success).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/generated/description-map.ts b/src/generated/description-map.ts index ec959b8..5551824 100644 --- a/src/generated/description-map.ts +++ b/src/generated/description-map.ts @@ -839,8 +839,8 @@ Unified entities typically also have additional enrichment making them valuable \`\`\` FIND UnifiedIdentity AS identity - THAT IS << User - THAT RELATES TO AS rel (Device|Host) + THAT IS << User + THAT RELATES TO AS rel (Device|Host) THAT IS >> UnifiedDevice AS device RETURN identity.displayName, rel._class, device.displayName \`\`\` @@ -1141,7 +1141,7 @@ Before running any J1QL query, verify: #### Most Common Errors (Quick Reference) 1. **Missing quotes**: \`name = john\` → \`name = 'john'\` -2. **Wrong quotes**: \`name = "john"\` → \`name = 'john'\` +2. **Wrong quotes**: \`name = "john"\` → \`name = 'john'\` 3. **Alias placement**: \`AS u WITH active = true\` → \`WITH active = true AS u\` 4. **WHERE needs alias**: \`WHERE active = true\` → \`AS u WHERE u.active = true\` 5. **Undefined alias**: \`FIND User RETURN u.name\` → \`FIND User AS u RETURN u.name\` @@ -1177,7 +1177,23 @@ Before running any J1QL query, verify: - LIMIT to prevent timeouts - Proper capitalization for classes -**Remember**: The execute-j1ql-query tool now provides enhanced error messages with specific suggestions. Always test queries here first!`, +**Remember**: The execute-j1ql-query tool now provides enhanced error messages with specific suggestions. Always test queries here first! + +#### 📌 IMPORTANT: Query Results URL + +When this tool returns query results, it includes a \`url\` field that provides a direct link to view the results in the JupiterOne UI. **Always share this URL with users when presenting query results** - it allows them to: +- View the data in an interactive table format +- Export results to CSV or other formats +- Save the query for future use +- Share results with team members +- Further refine the query in the JupiterOne UI + +Example response: +\`\`\`json +{ + "data": [...query results...], + "url": "https://your-account.apps.us.jupiterone.io/home/results?search=..." +}`, "get-dashboard-details.md": `# Get Dashboard Details Tool Get detailed information about a specific JupiterOne dashboard including its widgets, layout, and configuration. diff --git a/src/index.ts b/src/index.ts index b55bd11..0bc0994 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ async function main() { const config: JupiterOneConfig = { apiKey: process.env.JUPITERONE_API_KEY || '', accountId: process.env.JUPITERONE_ACCOUNT_ID || '', - baseUrl: process.env.JUPITERONE_BASE_URL || 'https://graphql.us.jupiterone.io', + baseUrl: process.env.JUPITERONE_GRAPHQL_URL || 'https://graphql.us.jupiterone.io', oauthToken: process.env.JUPITERONE_OAUTH_TOKEN || '', }; diff --git a/src/server/mcp-server.ts b/src/server/mcp-server.ts index ce53958..020de4a 100644 --- a/src/server/mcp-server.ts +++ b/src/server/mcp-server.ts @@ -14,6 +14,16 @@ import { } from '../types/jupiterone.js'; import { loadDescription } from '../utils/load-description.js'; import { J1QLValidator } from '../utils/j1ql-validator.js'; +import { + CreateInsightsWidgetInputSchema, + RuleTemplatesSchema, + QueryVariablesSchema, + QueryFlagsSchema, + ScopeFilterSchema, + ActionTargetValueSchema, + ActionDataSchema, + JiraAdditionalFieldsSchema, +} from './schemas.js'; export class JupiterOneMcpServer { public server: McpServer; @@ -894,7 +904,7 @@ export class JupiterOneMcpServer { outputs: z.array(z.string()).describe('Output fields from the rule evaluation'), specVersion: z.number().optional().describe('Specification version'), tags: z.array(z.string()).optional().describe('Tags for categorizing the rule'), - templates: z.record(z.any()).optional().describe('Template variables'), + templates: RuleTemplatesSchema.optional(), question: z .object({ queries: z.array( @@ -941,8 +951,7 @@ export class JupiterOneMcpServer { .string() .optional() .describe('Property to set (for SET_PROPERTY actions)'), - targetValue: z - .any() + targetValue: ActionTargetValueSchema .optional() .describe('Value to set (for SET_PROPERTY actions)'), integrationInstanceId: z @@ -960,7 +969,7 @@ export class JupiterOneMcpServer { .describe('Slack channels for SEND_SLACK_MESSAGE action'), bucket: z.string().optional().describe('S3 bucket name for SEND_TO_S3 action'), region: z.string().optional().describe('AWS region for SEND_TO_S3 action'), - data: z.any().optional().describe('Additional data for actions'), + data: ActionDataSchema.optional().describe('Additional data for actions'), entityClass: z .string() .optional() @@ -983,8 +992,7 @@ export class JupiterOneMcpServer { .describe( 'Whether to update content on changes for CREATE_JIRA_TICKET action' ), - additionalFields: z - .any() + additionalFields: JiraAdditionalFieldsSchema .optional() .describe('Additional fields for CREATE_JIRA_TICKET action'), }) @@ -1116,7 +1124,7 @@ export class JupiterOneMcpServer { outputs: z.array(z.string()).describe('Output fields from the rule evaluation'), specVersion: z.number().describe('Specification version'), tags: z.array(z.string()).describe('Tags for categorizing the rule'), - templates: z.record(z.any()).describe('Template variables'), + templates: RuleTemplatesSchema.describe('Template variables'), labels: z .array( z.object({ @@ -1165,8 +1173,7 @@ export class JupiterOneMcpServer { .string() .optional() .describe('Property to set (for SET_PROPERTY actions)'), - targetValue: z - .any() + targetValue: ActionTargetValueSchema .optional() .describe('Value to set (for SET_PROPERTY actions)'), integrationInstanceId: z @@ -1184,7 +1191,7 @@ export class JupiterOneMcpServer { .describe('Slack channels for SEND_SLACK_MESSAGE action'), bucket: z.string().optional().describe('S3 bucket name for SEND_TO_S3 action'), region: z.string().optional().describe('AWS region for SEND_TO_S3 action'), - data: z.any().optional().describe('Additional data for actions'), + data: ActionDataSchema.optional().describe('Additional data for actions'), entityClass: z .string() .optional() @@ -1199,8 +1206,7 @@ export class JupiterOneMcpServer { .boolean() .optional() .describe('Whether to update content on changes for CREATE_JIRA_TICKET action'), - additionalFields: z - .any() + additionalFields: JiraAdditionalFieldsSchema .optional() .describe('Additional fields for CREATE_JIRA_TICKET action'), entities: z.string().optional().describe('Entities for TAG_ENTITIES action'), @@ -1697,7 +1703,7 @@ export class JupiterOneMcpServer { description: loadDescription('create-dashboard-widget.md'), schema: { dashboardId: z.string().describe('ID of the dashboard to add the widget to'), - input: z.any().describe('Widget input object (CreateInsightsWidgetInput)'), + input: CreateInsightsWidgetInputSchema.describe('Widget input object (CreateInsightsWidgetInput)'), }, handler: async ({ dashboardId, input }, client, validator) => { try { @@ -1864,8 +1870,7 @@ export class JupiterOneMcpServer { description: loadDescription('execute-j1ql-query.md'), schema: { query: z.string().describe('A J1QL query string that describes what data to return'), - variables: z - .record(z.any()) + variables: QueryVariablesSchema .optional() .describe('A JSON map of values to be used as parameters for the query'), cursor: z @@ -1880,9 +1885,9 @@ export class JupiterOneMcpServer { .enum(['DISABLED', 'FORCE']) .optional() .describe('Allows for a deferred response to be returned'), - flags: z.record(z.any()).optional().describe('Flags for query execution'), + flags: QueryFlagsSchema.optional().describe('Flags for query execution'), scopeFilters: z - .array(z.record(z.any())) + .array(ScopeFilterSchema) .optional() .describe('Array of filters that define the desired vertex'), }, diff --git a/src/server/schemas.ts b/src/server/schemas.ts new file mode 100644 index 0000000..e2ac05c --- /dev/null +++ b/src/server/schemas.ts @@ -0,0 +1,199 @@ +import { z } from 'zod'; + +// Widget Chart Types +export const ChartTypeSchema = z.enum([ + 'area', + 'bar', + 'graph', + 'line', + 'matrix', + 'number', + 'pie', + 'table', + 'status', + 'markdown', +]); + +// Widget Query Schema +export const WidgetQuerySchema = z.object({ + query: z.string().describe('J1QL query string'), + name: z.string().describe('Name for the query'), + id: z.string().optional().describe('Optional ID for the query'), +}); + +// Settings schemas for different chart types +export const PieChartSettingsSchema = z.object({ + customColors: z.record(z.string()).optional().describe('Custom colors for pie slices'), + upwardTrendIsGood: z.boolean().optional().describe('Whether upward trend is positive'), + trendDataIsEnabled: z.boolean().optional().describe('Enable trend visualization'), + trendQueryResultsCount: z.number().optional().describe('Number of trend data points'), +}); + +export const BarChartSettingsSchema = z.object({ + stacked: z.boolean().optional().describe('Enable stacked bar chart'), + upwardTrendIsGood: z.boolean().optional().describe('Whether upward trend is positive'), + trendDataIsEnabled: z.boolean().optional().describe('Enable trend visualization'), +}); + +export const LineChartSettingsSchema = z.object({ + trendDataIsEnabled: z.boolean().optional().describe('Enable trend visualization'), +}); + +export const NumberChartSettingsSchema = z.object({ + upwardTrendIsGood: z.boolean().optional().describe('Whether upward trend is positive'), + trendDataIsEnabled: z.boolean().optional().describe('Enable trend visualization'), +}); + +export const MarkdownChartSettingsSchema = z.object({ + text: z.string().describe('Markdown content to display'), +}); + +// Generic chart settings for less common chart types +const GenericChartSettingsSchema = z.record(z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.unknown()), + z.record(z.unknown()), +])); + +// Combined settings schema that varies based on chart type +export const WidgetSettingsSchema = z.object({ + pie: PieChartSettingsSchema.optional(), + bar: BarChartSettingsSchema.optional(), + line: LineChartSettingsSchema.optional(), + number: NumberChartSettingsSchema.optional(), + markdown: MarkdownChartSettingsSchema.optional(), + area: GenericChartSettingsSchema.optional(), + graph: GenericChartSettingsSchema.optional(), + matrix: GenericChartSettingsSchema.optional(), + table: GenericChartSettingsSchema.optional(), + status: GenericChartSettingsSchema.optional(), +}); + +// Widget Config Schema +export const WidgetConfigSchema = z.object({ + queries: z + .array(WidgetQuerySchema) + .describe('Array of J1QL queries for the widget'), + settings: WidgetSettingsSchema.optional().describe('Chart-specific settings'), + postQueryFilters: z.record(z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.unknown()), + z.record(z.unknown()), + ])).optional().describe('Filters to apply after query execution'), + disableQueryPolicyFilters: z + .boolean() + .optional() + .describe('Whether to disable policy filters on queries'), +}); + +// Create Dashboard Widget Input Schema +export const CreateInsightsWidgetInputSchema = z.object({ + title: z.string().describe('Widget title'), + description: z.string().optional().describe('Widget description'), + type: ChartTypeSchema.describe('Type of chart/widget'), + noResultMessage: z.string().optional().describe('Message to display when no results'), + includeDeleted: z.boolean().optional().describe('Whether to include deleted entities'), + questionId: z.string().optional().describe('ID of an existing question to use'), + config: WidgetConfigSchema.describe('Widget configuration including queries and settings'), +}); + +// Templates Schema for rules +export const RuleTemplatesSchema = z.record( + z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.unknown()), + z.record(z.unknown()), + ]) +).describe('Template variables for the rule'); + +// J1QL Query Variables Schema +export const QueryVariablesSchema = z.record( + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(z.unknown()), + z.record(z.unknown()), + ]) +).describe('Variables to be used as parameters in the J1QL query'); + +// J1QL Query Flags Schema +export const QueryFlagsSchema = z.object({ + includeDeleted: z.boolean().optional().describe('Include deleted entities in results'), + deferredResponse: z.enum(['DISABLED', 'FORCE']).optional().describe('Deferred response mode'), + returnRowMetadata: z.boolean().optional().describe('Include metadata for each row'), + returnComputedProperties: z.boolean().optional().describe('Include computed properties'), +}).passthrough(); // Allow additional flags + +// Scope Filter Schema +export const ScopeFilterSchema = z.object({ + property: z.string().describe('Property to filter on'), + value: z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(z.unknown()), + z.record(z.unknown()), + ]).describe('Value to filter by'), + operator: z.string().optional().describe('Comparison operator'), +}).passthrough(); + +// Action target value schema (for rule actions) +export const ActionTargetValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.unknown()), + z.record(z.unknown()), +]).describe('Value to set for the action target'); + +// Action data schema +export const ActionDataSchema = z.union([ + z.string(), + z.object({ + description: z.string().optional(), + title: z.string().optional(), + content: z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(z.unknown()), + z.record(z.unknown()), + ]).optional(), + }), + z.record(z.unknown()), +]).describe('Additional data for the action'); + +// Additional fields for Jira tickets +export const JiraAdditionalFieldsSchema = z.object({ + description: z.union([ + z.string(), + z.object({ + type: z.literal('doc'), + version: z.number(), + content: z.array( + z.object({ + type: z.string(), + content: z.array( + z.object({ + type: z.string(), + text: z.string(), + }) + ), + }) + ), + }), + ]).optional(), + priority: z.string().optional(), + labels: z.array(z.string()).optional(), + components: z.array(z.string()).optional(), +}).passthrough(); // Allow additional Jira fields \ No newline at end of file diff --git a/src/utils/getEnv.ts b/src/utils/getEnv.ts index 02a2971..2fde43d 100644 --- a/src/utils/getEnv.ts +++ b/src/utils/getEnv.ts @@ -1,6 +1,6 @@ export const getEnv = () => { try { - const baseUrl = process.env.JUPITERONE_BASE_URL; + const baseUrl = process.env.JUPITERONE_GRAPHQL_URL; if (!baseUrl) { return undefined; } diff --git a/test-connection.js b/test-connection.js index e744e61..f372455 100644 --- a/test-connection.js +++ b/test-connection.js @@ -6,7 +6,7 @@ async function testConnection() { const config = { apiKey: process.env.JUPITERONE_API_KEY, accountId: process.env.JUPITERONE_ACCOUNT_ID || 'j1dev', - baseUrl: process.env.JUPITERONE_BASE_URL || 'https://graphql.dev.jupiterone.io' + baseUrl: process.env.JUPITERONE_GRAPHQL_URL || 'https://graphql.dev.jupiterone.io' }; if (!config.apiKey) {