@aiconnect/agentjobs-mcp 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/config.js +3 -1
- package/build/config.test.js +22 -0
- package/build/index.js +9 -2
- package/build/lib/agentJobsClient.js +11 -0
- package/build/tools/cancel_job.js +5 -2
- package/build/tools/create_job.js +1 -1
- package/build/tools/get_context.js +61 -0
- package/build/tools/get_jobs_stats.js +7 -17
- package/build/tools/list_jobs.js +7 -6
- package/build/utils/formatters.js +95 -20
- package/build/utils/formatters.test.js +243 -1
- package/build/utils/schemas.js +15 -11
- package/build/utils/schemas.test.js +73 -18
- package/build/utils/version.js +12 -0
- package/build/utils/version.test.js +9 -0
- package/docs/agent-jobs-api.md +41 -0
- package/package.json +3 -2
package/build/config.js
CHANGED
|
@@ -3,9 +3,11 @@ export const config = {
|
|
|
3
3
|
apiUrl: process.env.AICONNECT_API_URL || 'https://api.aiconnect.cloud/api/v0',
|
|
4
4
|
apiKey: process.env.AICONNECT_API_KEY || '',
|
|
5
5
|
defaultOrgId: process.env.DEFAULT_ORG_ID || 'aiconnect',
|
|
6
|
+
defaultTimezone: process.env.DEFAULT_TIMEZONE || 'UTC',
|
|
6
7
|
debugMode: process.env.DEBUG === 'true',
|
|
7
8
|
// Legacy compatibility
|
|
8
9
|
AICONNECT_API_URL: process.env.AICONNECT_API_URL || 'https://api.aiconnect.cloud/api/v0',
|
|
9
10
|
AICONNECT_API_KEY: process.env.AICONNECT_API_KEY || '',
|
|
10
|
-
DEFAULT_ORG_ID: process.env.DEFAULT_ORG_ID || 'aiconnect'
|
|
11
|
+
DEFAULT_ORG_ID: process.env.DEFAULT_ORG_ID || 'aiconnect',
|
|
12
|
+
DEFAULT_TIMEZONE: process.env.DEFAULT_TIMEZONE || 'UTC'
|
|
11
13
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
describe('config.defaultTimezone', () => {
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
});
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.unstubAllEnvs();
|
|
8
|
+
vi.resetModules();
|
|
9
|
+
});
|
|
10
|
+
it('reflects DEFAULT_TIMEZONE when set', async () => {
|
|
11
|
+
vi.stubEnv('DEFAULT_TIMEZONE', 'America/Sao_Paulo');
|
|
12
|
+
const { config } = await import('./config.js');
|
|
13
|
+
expect(config.defaultTimezone).toBe('America/Sao_Paulo');
|
|
14
|
+
expect(config.DEFAULT_TIMEZONE).toBe('America/Sao_Paulo');
|
|
15
|
+
});
|
|
16
|
+
it('falls back to UTC when DEFAULT_TIMEZONE is unset', async () => {
|
|
17
|
+
vi.stubEnv('DEFAULT_TIMEZONE', '');
|
|
18
|
+
const { config } = await import('./config.js');
|
|
19
|
+
expect(config.defaultTimezone).toBe('UTC');
|
|
20
|
+
expect(config.DEFAULT_TIMEZONE).toBe('UTC');
|
|
21
|
+
});
|
|
22
|
+
});
|
package/build/index.js
CHANGED
|
@@ -7,8 +7,11 @@ import { InitializeRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
|
7
7
|
import fs from "fs/promises";
|
|
8
8
|
import path from "path";
|
|
9
9
|
import { fileURLToPath } from "url";
|
|
10
|
-
|
|
10
|
+
import { mcpServerVersion } from "./utils/version.js";
|
|
11
|
+
// Get package metadata (description/homepage/author come from package.json directly;
|
|
12
|
+
// version comes from the shared helper to avoid duplication).
|
|
11
13
|
const packageJson = JSON.parse(await import('fs').then(fs => fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8')));
|
|
14
|
+
packageJson.version = mcpServerVersion;
|
|
12
15
|
// CLI argument parsing
|
|
13
16
|
const args = process.argv.slice(2);
|
|
14
17
|
// Help text
|
|
@@ -51,6 +54,8 @@ if (args.includes('--config') || args.includes('-c')) {
|
|
|
51
54
|
console.log('Current Configuration:');
|
|
52
55
|
console.log(` API URL: ${process.env.AICONNECT_API_URL || 'Not set'}`);
|
|
53
56
|
console.log(` API Key: ${process.env.AICONNECT_API_KEY ? '[SET]' : 'Not set'}`);
|
|
57
|
+
console.log(` Default Org: ${process.env.DEFAULT_ORG_ID || 'aiconnect'}`);
|
|
58
|
+
console.log(` Default Timezone: ${process.env.DEFAULT_TIMEZONE || 'UTC'}`);
|
|
54
59
|
console.log(` Node Version: ${process.version}`);
|
|
55
60
|
console.log(` MCP Server Version: ${packageJson.version}`);
|
|
56
61
|
process.exit(0);
|
|
@@ -74,7 +79,9 @@ console.error(`[DEBUG] Server version: ${packageJson.version}`);
|
|
|
74
79
|
console.error(`[DEBUG] Default capabilities: tools, resources, prompts`);
|
|
75
80
|
// Intercept initialization to detect protocol version
|
|
76
81
|
const originalSetRequestHandler = server.server.setRequestHandler.bind(server.server);
|
|
77
|
-
// Override initialization handler to capture protocol version
|
|
82
|
+
// Override initialization handler to capture protocol version.
|
|
83
|
+
// The schema is cast to `any` to short-circuit a TS2589 ("excessively deep") error
|
|
84
|
+
// from generic Zod inference in @modelcontextprotocol/sdk's setRequestHandler signature.
|
|
78
85
|
server.server.setRequestHandler(InitializeRequestSchema, async (request) => {
|
|
79
86
|
const initParams = request.params;
|
|
80
87
|
clientProtocolVersion = initParams.protocolVersion || "2024-11-05";
|
|
@@ -31,6 +31,17 @@ class AgentJobsClient {
|
|
|
31
31
|
this.handleError(error);
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
async listJobTypes(orgId, options = {}) {
|
|
35
|
+
const params = {
|
|
36
|
+
enrich: 'emoji',
|
|
37
|
+
limit: options.limit ?? 100,
|
|
38
|
+
sort: options.sort ?? 'name:asc'
|
|
39
|
+
};
|
|
40
|
+
if (options.offset !== undefined) {
|
|
41
|
+
params.offset = options.offset;
|
|
42
|
+
}
|
|
43
|
+
return (await this.getWithMeta(`/organizations/${orgId}/agent-jobs-type`, params));
|
|
44
|
+
}
|
|
34
45
|
async getStats(filters = {}) {
|
|
35
46
|
const params = {
|
|
36
47
|
...filters,
|
|
@@ -3,6 +3,7 @@ import agentJobsClient from "../lib/agentJobsClient.js";
|
|
|
3
3
|
import { formatJobSummary } from '../utils/formatters.js';
|
|
4
4
|
import { mcpDebugger, withTiming } from '../utils/debugger.js';
|
|
5
5
|
export default (server) => {
|
|
6
|
+
// @ts-expect-error TS2589: registerTool generic Zod inference exceeds TS depth limit with this schema.
|
|
6
7
|
server.registerTool("cancel_job", {
|
|
7
8
|
description: "Cancels an agent job by its ID.",
|
|
8
9
|
annotations: {
|
|
@@ -10,9 +11,11 @@ export default (server) => {
|
|
|
10
11
|
},
|
|
11
12
|
inputSchema: {
|
|
12
13
|
job_id: z.string({
|
|
13
|
-
description: "The unique identifier of the job to be canceled. Example: 'job-12345'."
|
|
14
|
+
description: "The unique identifier of the job to be canceled. Example: 'job-12345'."
|
|
14
15
|
}),
|
|
15
|
-
reason: z.string(
|
|
16
|
+
reason: z.string({
|
|
17
|
+
description: "An optional reason explaining why the job is being canceled."
|
|
18
|
+
}).optional()
|
|
16
19
|
}
|
|
17
20
|
}, async (params) => {
|
|
18
21
|
mcpDebugger.toolCall("cancel_job", params);
|
|
@@ -32,7 +32,7 @@ export default (server) => {
|
|
|
32
32
|
.record(z.any())
|
|
33
33
|
.optional()
|
|
34
34
|
.describe('Free‑form params passed to the agent'),
|
|
35
|
-
scheduled_at: flexibleDateTimeSchema
|
|
35
|
+
scheduled_at: flexibleDateTimeSchema()
|
|
36
36
|
.optional()
|
|
37
37
|
.describe('Schedule the job to run later')
|
|
38
38
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import agentJobsClient from '../lib/agentJobsClient.js';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { formatContext } from '../utils/formatters.js';
|
|
5
|
+
import { mcpServerVersion } from '../utils/version.js';
|
|
6
|
+
import { mcpDebugger, withTiming } from '../utils/debugger.js';
|
|
7
|
+
export default (server) => {
|
|
8
|
+
server.registerTool('get_context', {
|
|
9
|
+
description: 'Returns the MCP server runtime context: local defaults (org_id, timezone, API URL, server version) plus the list of agent job types available in the effective organization. Designed to be the first call an LLM client makes — surfaces what would otherwise be invisible env vars and avoids guessing valid job type IDs before calling create_job.',
|
|
10
|
+
annotations: {
|
|
11
|
+
title: 'Get MCP Server Runtime Context'
|
|
12
|
+
},
|
|
13
|
+
inputSchema: {
|
|
14
|
+
org_id: z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Override DEFAULT_ORG_ID for this call (introspect a different org without restarting the server)')
|
|
18
|
+
}
|
|
19
|
+
}, async (params) => {
|
|
20
|
+
mcpDebugger.toolCall('get_context', params);
|
|
21
|
+
const effectiveOrgId = params.org_id || config.DEFAULT_ORG_ID;
|
|
22
|
+
const localConfig = {
|
|
23
|
+
org_id: effectiveOrgId,
|
|
24
|
+
timezone: config.DEFAULT_TIMEZONE,
|
|
25
|
+
api_url: config.AICONNECT_API_URL,
|
|
26
|
+
server_version: mcpServerVersion
|
|
27
|
+
};
|
|
28
|
+
let jobTypes;
|
|
29
|
+
let total;
|
|
30
|
+
let jobTypesError;
|
|
31
|
+
try {
|
|
32
|
+
const response = await withTiming(() => agentJobsClient.listJobTypes(effectiveOrgId), 'get_context.listJobTypes API call');
|
|
33
|
+
jobTypes = (response?.data ?? []).map((jt) => ({
|
|
34
|
+
id: jt.id,
|
|
35
|
+
name: jt.name,
|
|
36
|
+
description: jt.description,
|
|
37
|
+
emoji: jt.emoji
|
|
38
|
+
}));
|
|
39
|
+
total = response?.meta?.total ?? jobTypes.length;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
jobTypesError = error?.message ?? String(error);
|
|
43
|
+
mcpDebugger.toolError('get_context.listJobTypes', error);
|
|
44
|
+
}
|
|
45
|
+
const text = formatContext({ localConfig, jobTypes, total, jobTypesError });
|
|
46
|
+
mcpDebugger.toolResponse('get_context', {
|
|
47
|
+
org_id: effectiveOrgId,
|
|
48
|
+
jobTypesCount: jobTypes?.length,
|
|
49
|
+
hasError: !!jobTypesError,
|
|
50
|
+
resultLength: text.length
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: 'text',
|
|
56
|
+
text
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
};
|
|
@@ -3,42 +3,32 @@ import agentJobsClient from "../lib/agentJobsClient.js";
|
|
|
3
3
|
import { formatJobStats } from "../utils/formatters.js";
|
|
4
4
|
import { flexibleDateTimeSchema } from "../utils/schemas.js";
|
|
5
5
|
import { mcpDebugger, withTiming } from "../utils/debugger.js";
|
|
6
|
-
const jobStatusSchema = z.enum([
|
|
7
|
-
"waiting",
|
|
8
|
-
"scheduled",
|
|
9
|
-
"running",
|
|
10
|
-
"completed",
|
|
11
|
-
"failed",
|
|
12
|
-
"canceled"
|
|
13
|
-
]);
|
|
14
6
|
export default (server) => {
|
|
15
7
|
server.registerTool("get_jobs_stats", {
|
|
16
|
-
description: "
|
|
8
|
+
description: "Returns aggregated job counts broken down by status (waiting/scheduled/running/completed/failed/canceled) plus a summary (total, success rate, active, completion rate). To filter to a single status, use `list_jobs` with `status=` instead — this tool intentionally does not expose a `status` filter because the upstream stats endpoint ignores it (the breakdown is itself by status). Filters available here narrow the universe along orthogonal dimensions: `job_type_id`, `channel_code`, `tags`, `scheduled_at_*`, `created_at_*`. Result-code and duration aggregates are not yet available; for those today, fall back to `list_jobs` and aggregate client-side. Optimized for dashboards and monitoring with minimal network overhead.",
|
|
17
9
|
annotations: {
|
|
18
10
|
title: "Get Job Statistics"
|
|
19
11
|
},
|
|
20
12
|
inputSchema: {
|
|
21
13
|
org_id: z.string().optional().describe("Filter by organization ID."),
|
|
22
|
-
scheduled_at_gte: flexibleDateTimeSchema.optional().describe("Start of period (ISO 8601)"),
|
|
23
|
-
scheduled_at_lte: flexibleDateTimeSchema.optional().describe("End of period (ISO 8601)"),
|
|
24
|
-
created_at_gte: flexibleDateTimeSchema.optional().describe("Filter for jobs created at or after a specific time (ISO 8601)."),
|
|
25
|
-
created_at_lte: flexibleDateTimeSchema.optional().describe("Filter for jobs created at or before a specific time (ISO 8601)."),
|
|
14
|
+
scheduled_at_gte: flexibleDateTimeSchema().optional().describe("Start of period (ISO 8601)"),
|
|
15
|
+
scheduled_at_lte: flexibleDateTimeSchema().optional().describe("End of period (ISO 8601)"),
|
|
16
|
+
created_at_gte: flexibleDateTimeSchema().optional().describe("Filter for jobs created at or after a specific time (ISO 8601)."),
|
|
17
|
+
created_at_lte: flexibleDateTimeSchema().optional().describe("Filter for jobs created at or before a specific time (ISO 8601)."),
|
|
26
18
|
job_type_id: z.string().optional().describe("Job type filter"),
|
|
27
19
|
channel_code: z.string().optional().describe("Channel filter"),
|
|
28
20
|
tags: z.string().optional().describe("Tags filter (comma-separated)"),
|
|
29
|
-
status: jobStatusSchema.optional().describe("Status filter"),
|
|
30
21
|
}
|
|
31
22
|
}, async (params) => {
|
|
32
23
|
mcpDebugger.toolCall("get_jobs_stats", params);
|
|
33
24
|
try {
|
|
34
25
|
const response = await withTiming(() => agentJobsClient.getStats(params), "get_jobs_stats API call");
|
|
35
26
|
const stats = response.meta?.stats || {};
|
|
36
|
-
|
|
37
|
-
mcpDebugger.debug("Raw API response", { stats, filters });
|
|
27
|
+
mcpDebugger.debug("Raw API response", { stats, appliedFilters: params });
|
|
38
28
|
const result = {
|
|
39
29
|
content: [{
|
|
40
30
|
type: "text",
|
|
41
|
-
text: formatJobStats(stats,
|
|
31
|
+
text: formatJobStats(stats, params),
|
|
42
32
|
}]
|
|
43
33
|
};
|
|
44
34
|
mcpDebugger.toolResponse("get_jobs_stats", result);
|
package/build/tools/list_jobs.js
CHANGED
|
@@ -21,11 +21,11 @@ export default (server) => {
|
|
|
21
21
|
inputSchema: {
|
|
22
22
|
org_id: z.string().optional().describe("Filter by organization ID. If not provided, the default from the environment is used."),
|
|
23
23
|
status: jobStatusSchema.optional().describe("Filter by job status. Possible values are: 'waiting', 'scheduled', 'running', 'completed', 'failed', 'canceled'."),
|
|
24
|
-
scheduled_at: flexibleDateTimeSchema.optional().describe("Filter by the exact scheduled time in ISO 8601 format (e.g., '2024-07-23T10:00:00Z')."),
|
|
25
|
-
scheduled_at_gte: flexibleDateTimeSchema.optional().describe("Filter for jobs scheduled at or after a specific time (ISO 8601)."),
|
|
26
|
-
scheduled_at_lte: flexibleDateTimeSchema.optional().describe("Filter for jobs scheduled at or before a specific time (ISO 8601)."),
|
|
27
|
-
created_at_gte: flexibleDateTimeSchema.optional().describe("Filter for jobs created at or after a specific time (ISO 8601)."),
|
|
28
|
-
created_at_lte: flexibleDateTimeSchema.optional().describe("Filter for jobs created at or before a specific time (ISO 8601)."),
|
|
24
|
+
scheduled_at: flexibleDateTimeSchema().optional().describe("Filter by the exact scheduled time in ISO 8601 format (e.g., '2024-07-23T10:00:00Z')."),
|
|
25
|
+
scheduled_at_gte: flexibleDateTimeSchema().optional().describe("Filter for jobs scheduled at or after a specific time (ISO 8601)."),
|
|
26
|
+
scheduled_at_lte: flexibleDateTimeSchema().optional().describe("Filter for jobs scheduled at or before a specific time (ISO 8601)."),
|
|
27
|
+
created_at_gte: flexibleDateTimeSchema().optional().describe("Filter for jobs created at or after a specific time (ISO 8601)."),
|
|
28
|
+
created_at_lte: flexibleDateTimeSchema().optional().describe("Filter for jobs created at or before a specific time (ISO 8601)."),
|
|
29
29
|
job_type_id: z.string().optional().describe("Filter by the specific job type ID (e.g., 'daily-report')."),
|
|
30
30
|
channel_code: z.string().optional().describe("Filter by the channel code (e.g., 'C123456' for a Slack channel)."),
|
|
31
31
|
limit: z.number().int().positive().optional().describe("Maximum number of jobs to return (e.g.,20). Default is 20."),
|
|
@@ -54,10 +54,11 @@ export default (server) => {
|
|
|
54
54
|
meta,
|
|
55
55
|
firstJob: jobs[0] || null
|
|
56
56
|
});
|
|
57
|
+
const offset = typeof params.offset === 'number' ? params.offset : 0;
|
|
57
58
|
const result = {
|
|
58
59
|
content: [{
|
|
59
60
|
type: "text",
|
|
60
|
-
text: formatJobList(jobs, meta),
|
|
61
|
+
text: formatJobList(jobs, meta, offset),
|
|
61
62
|
}]
|
|
62
63
|
};
|
|
63
64
|
mcpDebugger.toolResponse("list_jobs", {
|
|
@@ -216,17 +216,33 @@ export function formatJobSummary(job) {
|
|
|
216
216
|
}
|
|
217
217
|
/**
|
|
218
218
|
* Formata a resposta para a lista de jobs.
|
|
219
|
+
*
|
|
220
|
+
* `meta` é obrigatório e os três campos (`count`, `limit`, `total`) precisam
|
|
221
|
+
* ser numéricos — confirmado empiricamente que o backend sempre os retorna em
|
|
222
|
+
* `/services/agent-jobs`. Falha explícita aqui é preferível a inventar um
|
|
223
|
+
* footer com valores inferidos, pois isso recriaria a ambiguidade
|
|
224
|
+
* "page count vs total" que esta tool deveria eliminar.
|
|
225
|
+
*
|
|
219
226
|
* @param jobs - Um array de jobs.
|
|
220
|
-
* @param
|
|
227
|
+
* @param meta - O objeto `meta` retornado pelo backend.
|
|
228
|
+
* @param offset - O `offset` que a tool enviou na requisição (default 0).
|
|
221
229
|
* @returns Uma string formatada com a lista de resumos de jobs.
|
|
222
230
|
*/
|
|
223
|
-
export function formatJobList(jobs,
|
|
224
|
-
if (!
|
|
225
|
-
|
|
231
|
+
export function formatJobList(jobs, meta, offset = 0) {
|
|
232
|
+
if (!meta || typeof meta.count !== 'number' || typeof meta.limit !== 'number' || typeof meta.total !== 'number') {
|
|
233
|
+
throw new Error('formatJobList: meta is required with numeric `count`, `limit`, and `total`. ' +
|
|
234
|
+
`Received: ${JSON.stringify(meta)}`);
|
|
226
235
|
}
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
|
|
236
|
+
const safeJobs = jobs || [];
|
|
237
|
+
const { count, limit, total } = meta;
|
|
238
|
+
const hasMore = (offset + count) < total;
|
|
239
|
+
const nextOffset = hasMore ? offset + limit : null;
|
|
240
|
+
const footer = `Returned: ${count} | Total matching: ${total} | Has more: ${hasMore} | Next offset: ${nextOffset === null ? 'null' : nextOffset}`;
|
|
241
|
+
if (safeJobs.length === 0) {
|
|
242
|
+
return `Found 0 jobs.\n\n${footer}`;
|
|
243
|
+
}
|
|
244
|
+
const jobSummaries = safeJobs.map(job => formatJobSummary(job)).join('\n\n');
|
|
245
|
+
return `Found ${safeJobs.length} jobs.\n\n${jobSummaries}\n\n${footer}`;
|
|
230
246
|
}
|
|
231
247
|
// Schema for job type details
|
|
232
248
|
const jobTypeSchema = z.object({
|
|
@@ -454,21 +470,81 @@ export function formatJobTypeSummary(jobType) {
|
|
|
454
470
|
return JSON.stringify(jobType, null, 2);
|
|
455
471
|
}
|
|
456
472
|
}
|
|
457
|
-
|
|
473
|
+
const DATE_FILTER_KEYS = [
|
|
474
|
+
'scheduled_at_gte',
|
|
475
|
+
'scheduled_at_lte',
|
|
476
|
+
'created_at_gte',
|
|
477
|
+
'created_at_lte',
|
|
478
|
+
];
|
|
479
|
+
const renderDateRange = (label, gte, lte) => {
|
|
480
|
+
if (gte === undefined && lte === undefined)
|
|
481
|
+
return null;
|
|
482
|
+
const left = gte !== undefined ? String(gte) : '(open)';
|
|
483
|
+
const right = lte !== undefined ? String(lte) : '(open)';
|
|
484
|
+
return `${label}: ${left} → ${right}`;
|
|
485
|
+
};
|
|
486
|
+
const CONTEXT_LABEL_WIDTH = 16; // "Server version: " == 16 chars
|
|
487
|
+
const JOB_TYPE_ID_WIDTH = 22;
|
|
488
|
+
const JOB_TYPE_EMOJI_WIDTH = 4;
|
|
489
|
+
function padLabel(label) {
|
|
490
|
+
return (label + ':').padEnd(CONTEXT_LABEL_WIDTH, ' ');
|
|
491
|
+
}
|
|
492
|
+
function formatJobTypeLine(jt) {
|
|
493
|
+
const id = jt.id.padEnd(JOB_TYPE_ID_WIDTH, ' ');
|
|
494
|
+
const emoji = (jt.emoji ?? '').padEnd(JOB_TYPE_EMOJI_WIDTH, ' ');
|
|
495
|
+
const description = jt.description ? ` — ${jt.description}` : '';
|
|
496
|
+
return ` - ${id} ${emoji} ${jt.name}${description}`;
|
|
497
|
+
}
|
|
498
|
+
export function formatContext(input) {
|
|
499
|
+
const { localConfig, jobTypes, total, jobTypesError } = input;
|
|
500
|
+
const contextSection = [
|
|
501
|
+
'Context:',
|
|
502
|
+
` ${padLabel('Org ID')} ${localConfig.org_id}`,
|
|
503
|
+
` ${padLabel('Timezone')} ${localConfig.timezone}`,
|
|
504
|
+
` ${padLabel('API URL')} ${localConfig.api_url}`,
|
|
505
|
+
` ${padLabel('Server version')} ${localConfig.server_version}`
|
|
506
|
+
].join('\n');
|
|
507
|
+
let jobsSection;
|
|
508
|
+
if (jobTypesError !== undefined) {
|
|
509
|
+
jobsSection = `Job types: unavailable (error: ${jobTypesError})`;
|
|
510
|
+
}
|
|
511
|
+
else if (jobTypes === undefined) {
|
|
512
|
+
jobsSection = 'Job types: unavailable (error: unknown)';
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
const totalCount = typeof total === 'number' ? total : jobTypes.length;
|
|
516
|
+
if (totalCount === 0) {
|
|
517
|
+
jobsSection = 'Job types available (0):\n (no job types registered for this org)';
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
const lines = jobTypes.map(formatJobTypeLine);
|
|
521
|
+
let trailing = '';
|
|
522
|
+
if (totalCount > jobTypes.length) {
|
|
523
|
+
const missing = totalCount - jobTypes.length;
|
|
524
|
+
trailing = `\n … and ${missing} more job types not shown`;
|
|
525
|
+
}
|
|
526
|
+
jobsSection = `Job types available (${totalCount}):\n${lines.join('\n')}${trailing}`;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return `${contextSection}\n\n${jobsSection}`;
|
|
530
|
+
}
|
|
531
|
+
export function formatJobStats(stats, appliedFilters = {}) {
|
|
458
532
|
const { waiting = 0, running = 0, completed = 0, failed = 0, canceled = 0, scheduled = 0, } = stats.status;
|
|
459
533
|
const totalJobs = waiting + running + completed + failed + canceled + scheduled;
|
|
460
534
|
const successRate = totalJobs > 0 ? ((completed / (totalJobs - waiting - scheduled - running)) * 100).toFixed(1) : "0.0";
|
|
461
535
|
const completionRate = (completed + failed) > 0 ? (completed / (completed + failed) * 100).toFixed(1) : "0.0";
|
|
462
536
|
const activeJobs = running + waiting + scheduled;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const
|
|
537
|
+
const filters = appliedFilters || {};
|
|
538
|
+
const scheduledLine = renderDateRange('Scheduled', filters.scheduled_at_gte, filters.scheduled_at_lte);
|
|
539
|
+
const createdLine = renderDateRange('Created', filters.created_at_gte, filters.created_at_lte);
|
|
540
|
+
const hasAnyDateFilter = DATE_FILTER_KEYS.some((k) => filters[k] !== undefined);
|
|
541
|
+
const periodFallback = hasAnyDateFilter ? null : 'Period: All time';
|
|
542
|
+
const dateLines = [scheduledLine, createdLine, periodFallback].filter((l) => l !== null);
|
|
543
|
+
const dateFilterSet = new Set(DATE_FILTER_KEYS);
|
|
544
|
+
const otherFilterEntries = Object.entries(filters).filter(([k, v]) => !dateFilterSet.has(k) && v !== undefined && v !== null && v !== '');
|
|
545
|
+
const filtersSection = otherFilterEntries.length
|
|
546
|
+
? `Filters:\n${otherFilterEntries.map(([k, v]) => `- ${k}: ${v}`).join('\n')}\n\n`
|
|
547
|
+
: '';
|
|
472
548
|
const percentage = (value) => {
|
|
473
549
|
if (totalJobs === 0)
|
|
474
550
|
return "0.0";
|
|
@@ -478,10 +554,9 @@ export function formatJobStats(stats, filters) {
|
|
|
478
554
|
Job Statistics Report
|
|
479
555
|
====================
|
|
480
556
|
|
|
481
|
-
|
|
482
|
-
${org}
|
|
557
|
+
${dateLines.join('\n')}
|
|
483
558
|
|
|
484
|
-
Status Breakdown:
|
|
559
|
+
${filtersSection}Status Breakdown:
|
|
485
560
|
✓ Completed: ${completed} jobs (${percentage(completed)}%)
|
|
486
561
|
⏳ Running: ${running} jobs (${percentage(running)}%)
|
|
487
562
|
⏰ Scheduled: ${scheduled} jobs (${percentage(scheduled)}%)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { formatJobDetails, formatJobStats, formatJobTypeDetails, formatJobTypeSummary } from './formatters.js';
|
|
2
|
+
import { formatJobDetails, formatJobList, formatJobStats, formatJobTypeDetails, formatJobTypeSummary } from './formatters.js';
|
|
3
3
|
describe('formatJobDetails', () => {
|
|
4
4
|
const fullJob = {
|
|
5
5
|
job_id: 'job_123',
|
|
@@ -133,6 +133,168 @@ describe('formatJobStats', () => {
|
|
|
133
133
|
const result = formatJobStats(stats, filters);
|
|
134
134
|
expect(result).toContain('Period: All time');
|
|
135
135
|
});
|
|
136
|
+
describe('header — Scheduled date range', () => {
|
|
137
|
+
const stats = {
|
|
138
|
+
status: { completed: 1, running: 0, failed: 0, canceled: 0, waiting: 0, scheduled: 0 },
|
|
139
|
+
};
|
|
140
|
+
it('renders both bounds when scheduled_at_gte and scheduled_at_lte are provided', () => {
|
|
141
|
+
const result = formatJobStats(stats, {
|
|
142
|
+
scheduled_at_gte: '2026-04-30T00:00:00Z',
|
|
143
|
+
scheduled_at_lte: '2026-05-01T00:00:00Z',
|
|
144
|
+
});
|
|
145
|
+
expect(result).toContain('Scheduled: 2026-04-30T00:00:00Z → 2026-05-01T00:00:00Z');
|
|
146
|
+
expect(result).not.toContain('Period: All time');
|
|
147
|
+
});
|
|
148
|
+
it('renders "(open)" upper bound when only scheduled_at_gte is provided', () => {
|
|
149
|
+
const result = formatJobStats(stats, {
|
|
150
|
+
scheduled_at_gte: '2026-04-30T00:00:00Z',
|
|
151
|
+
});
|
|
152
|
+
expect(result).toContain('Scheduled: 2026-04-30T00:00:00Z → (open)');
|
|
153
|
+
});
|
|
154
|
+
it('renders "(open)" lower bound when only scheduled_at_lte is provided', () => {
|
|
155
|
+
const result = formatJobStats(stats, {
|
|
156
|
+
scheduled_at_lte: '2026-05-01T00:00:00Z',
|
|
157
|
+
});
|
|
158
|
+
expect(result).toContain('Scheduled: (open) → 2026-05-01T00:00:00Z');
|
|
159
|
+
});
|
|
160
|
+
it('omits the Scheduled range line entirely when no scheduled_at bound is provided', () => {
|
|
161
|
+
const result = formatJobStats(stats, { status: 'failed' });
|
|
162
|
+
// The Status Breakdown contains "⏰ Scheduled:"; we only want to assert
|
|
163
|
+
// the header-range form ("Scheduled: <bound> → ...") is absent.
|
|
164
|
+
expect(result).not.toMatch(/(^|\n)Scheduled: .+→/);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
describe('header — Created date range', () => {
|
|
168
|
+
const stats = {
|
|
169
|
+
status: { completed: 1, running: 0, failed: 0, canceled: 0, waiting: 0, scheduled: 0 },
|
|
170
|
+
};
|
|
171
|
+
it('renders both bounds when created_at_gte and created_at_lte are provided', () => {
|
|
172
|
+
const result = formatJobStats(stats, {
|
|
173
|
+
created_at_gte: '2026-04-01T00:00:00Z',
|
|
174
|
+
created_at_lte: '2026-04-30T00:00:00Z',
|
|
175
|
+
});
|
|
176
|
+
expect(result).toContain('Created: 2026-04-01T00:00:00Z → 2026-04-30T00:00:00Z');
|
|
177
|
+
});
|
|
178
|
+
it('renders "(open)" upper bound when only created_at_gte is provided', () => {
|
|
179
|
+
const result = formatJobStats(stats, {
|
|
180
|
+
created_at_gte: '2026-04-01T00:00:00Z',
|
|
181
|
+
});
|
|
182
|
+
expect(result).toContain('Created: 2026-04-01T00:00:00Z → (open)');
|
|
183
|
+
});
|
|
184
|
+
it('renders "(open)" lower bound when only created_at_lte is provided', () => {
|
|
185
|
+
const result = formatJobStats(stats, {
|
|
186
|
+
created_at_lte: '2026-04-30T00:00:00Z',
|
|
187
|
+
});
|
|
188
|
+
expect(result).toContain('Created: (open) → 2026-04-30T00:00:00Z');
|
|
189
|
+
});
|
|
190
|
+
it('omits the Created line entirely when no created_at bound is provided', () => {
|
|
191
|
+
const result = formatJobStats(stats, { scheduled_at_gte: '2026-04-30T00:00:00Z' });
|
|
192
|
+
expect(result).not.toContain('Created:');
|
|
193
|
+
});
|
|
194
|
+
it('renders both Scheduled and Created lines when both ranges are filtered', () => {
|
|
195
|
+
const result = formatJobStats(stats, {
|
|
196
|
+
scheduled_at_gte: '2026-04-30T00:00:00Z',
|
|
197
|
+
created_at_gte: '2026-04-01T00:00:00Z',
|
|
198
|
+
});
|
|
199
|
+
expect(result).toContain('Scheduled: 2026-04-30T00:00:00Z → (open)');
|
|
200
|
+
expect(result).toContain('Created: 2026-04-01T00:00:00Z → (open)');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
describe('header — Period: All time fallback', () => {
|
|
204
|
+
const stats = {
|
|
205
|
+
status: { completed: 1, running: 0, failed: 0, canceled: 0, waiting: 0, scheduled: 0 },
|
|
206
|
+
};
|
|
207
|
+
it('renders "Period: All time" when no date filter is provided', () => {
|
|
208
|
+
const result = formatJobStats(stats, {});
|
|
209
|
+
expect(result).toContain('Period: All time');
|
|
210
|
+
});
|
|
211
|
+
it('does not render "Period: All time" when scheduled_at_gte is set', () => {
|
|
212
|
+
const result = formatJobStats(stats, { scheduled_at_gte: '2026-04-30T00:00:00Z' });
|
|
213
|
+
expect(result).not.toContain('Period: All time');
|
|
214
|
+
});
|
|
215
|
+
it('does not render "Period: All time" when created_at_lte is set', () => {
|
|
216
|
+
const result = formatJobStats(stats, { created_at_lte: '2026-04-30T00:00:00Z' });
|
|
217
|
+
expect(result).not.toContain('Period: All time');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('header — Filters section', () => {
|
|
221
|
+
const stats = {
|
|
222
|
+
status: { completed: 1, running: 0, failed: 0, canceled: 0, waiting: 0, scheduled: 0 },
|
|
223
|
+
};
|
|
224
|
+
it('renders a single line when one non-date filter is supplied', () => {
|
|
225
|
+
const result = formatJobStats(stats, { status: 'failed' });
|
|
226
|
+
expect(result).toContain('Filters:');
|
|
227
|
+
expect(result).toContain('- status: failed');
|
|
228
|
+
});
|
|
229
|
+
it('renders multiple lines when multiple non-date filters are supplied', () => {
|
|
230
|
+
const result = formatJobStats(stats, {
|
|
231
|
+
status: 'failed',
|
|
232
|
+
job_type_id: 'woba-supplier-ai-batch',
|
|
233
|
+
});
|
|
234
|
+
expect(result).toContain('- status: failed');
|
|
235
|
+
expect(result).toContain('- job_type_id: woba-supplier-ai-batch');
|
|
236
|
+
});
|
|
237
|
+
it('omits the Filters section entirely when only date filters are supplied', () => {
|
|
238
|
+
const result = formatJobStats(stats, { scheduled_at_gte: '2026-04-30T00:00:00Z' });
|
|
239
|
+
expect(result).not.toContain('Filters:');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
describe('formatJobList', () => {
|
|
244
|
+
const sampleJob = {
|
|
245
|
+
job_id: 'job_1',
|
|
246
|
+
channel_code: 'ch_1',
|
|
247
|
+
created_at: '2026-04-30T00:00:00.000Z',
|
|
248
|
+
updated_at: '2026-04-30T00:10:00.000Z',
|
|
249
|
+
scheduled_at: '2026-04-30T00:00:00.000Z',
|
|
250
|
+
job_status: 'completed',
|
|
251
|
+
result: 'ok',
|
|
252
|
+
job_type_id: 'type_1',
|
|
253
|
+
};
|
|
254
|
+
it('renders the four-field footer on first page with more pages available', () => {
|
|
255
|
+
const jobs = Array.from({ length: 20 }, (_, i) => ({ ...sampleJob, job_id: `job_${i}` }));
|
|
256
|
+
const meta = { count: 20, limit: 20, total: 40 };
|
|
257
|
+
const result = formatJobList(jobs, meta, 0);
|
|
258
|
+
expect(result).toContain('Returned: 20 | Total matching: 40 | Has more: true | Next offset: 20');
|
|
259
|
+
expect(result).not.toContain('Page:');
|
|
260
|
+
expect(result).not.toContain('Total Jobs:');
|
|
261
|
+
});
|
|
262
|
+
it('renders Has more: false and Next offset: null on the last page', () => {
|
|
263
|
+
const jobs = Array.from({ length: 20 }, (_, i) => ({ ...sampleJob, job_id: `job_${i}` }));
|
|
264
|
+
const meta = { count: 20, limit: 20, total: 40 };
|
|
265
|
+
const result = formatJobList(jobs, meta, 20);
|
|
266
|
+
expect(result).toContain('Returned: 20 | Total matching: 40 | Has more: false | Next offset: null');
|
|
267
|
+
});
|
|
268
|
+
it('handles a partial last page (count < limit)', () => {
|
|
269
|
+
const jobs = Array.from({ length: 5 }, (_, i) => ({ ...sampleJob, job_id: `job_${i}` }));
|
|
270
|
+
const meta = { count: 5, limit: 20, total: 25 };
|
|
271
|
+
const result = formatJobList(jobs, meta, 20);
|
|
272
|
+
expect(result).toContain('Returned: 5 | Total matching: 25 | Has more: false | Next offset: null');
|
|
273
|
+
});
|
|
274
|
+
it('renders the footer on an empty result on the first page', () => {
|
|
275
|
+
const meta = { count: 0, limit: 20, total: 0 };
|
|
276
|
+
const result = formatJobList([], meta, 0);
|
|
277
|
+
expect(result).toContain('Found 0 jobs.');
|
|
278
|
+
expect(result).toContain('Returned: 0 | Total matching: 0 | Has more: false | Next offset: null');
|
|
279
|
+
expect(result).not.toContain('No jobs found for the given criteria.');
|
|
280
|
+
});
|
|
281
|
+
it('renders Total matching reflecting real total when offset overflows the result set', () => {
|
|
282
|
+
const meta = { count: 0, limit: 20, total: 40 };
|
|
283
|
+
const result = formatJobList([], meta, 100);
|
|
284
|
+
expect(result).toContain('Found 0 jobs.');
|
|
285
|
+
expect(result).toContain('Returned: 0 | Total matching: 40 | Has more: false | Next offset: null');
|
|
286
|
+
});
|
|
287
|
+
describe('fail-fast on missing meta fields', () => {
|
|
288
|
+
it('throws when meta is null', () => {
|
|
289
|
+
expect(() => formatJobList([], null, 0)).toThrow(/meta is required/);
|
|
290
|
+
});
|
|
291
|
+
it('throws when meta.total is missing', () => {
|
|
292
|
+
expect(() => formatJobList([], { count: 0, limit: 20 }, 0)).toThrow(/meta is required.*count.*limit.*total/);
|
|
293
|
+
});
|
|
294
|
+
it('throws when meta.count is non-numeric', () => {
|
|
295
|
+
expect(() => formatJobList([], { count: 'oops', limit: 20, total: 0 }, 0)).toThrow(/meta is required/);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
136
298
|
});
|
|
137
299
|
describe('formatJobTypeDetails', () => {
|
|
138
300
|
const fullJobType = {
|
|
@@ -385,3 +547,83 @@ describe('formatJobTypeSummary', () => {
|
|
|
385
547
|
expect(result).toContain('"invalid": true');
|
|
386
548
|
});
|
|
387
549
|
});
|
|
550
|
+
import { formatContext } from './formatters.js';
|
|
551
|
+
describe('formatContext', () => {
|
|
552
|
+
const localConfig = {
|
|
553
|
+
org_id: 'woba',
|
|
554
|
+
timezone: 'America/Sao_Paulo',
|
|
555
|
+
api_url: 'https://api.aiconnect.cloud/api/v0',
|
|
556
|
+
server_version: '0.4.2'
|
|
557
|
+
};
|
|
558
|
+
it('formats happy path with two job types', () => {
|
|
559
|
+
const result = formatContext({
|
|
560
|
+
localConfig,
|
|
561
|
+
total: 2,
|
|
562
|
+
jobTypes: [
|
|
563
|
+
{ id: 'billing-followup', name: 'Billing Follow-up', description: 'Triage de cobrança', emoji: '💳' },
|
|
564
|
+
{ id: 'support-triage', name: 'Support Triage', description: 'Roteamento de suporte', emoji: '🛠️' }
|
|
565
|
+
]
|
|
566
|
+
});
|
|
567
|
+
expect(result).toContain('Context:');
|
|
568
|
+
expect(result).toContain('Org ID: woba');
|
|
569
|
+
expect(result).toContain('Timezone: America/Sao_Paulo');
|
|
570
|
+
expect(result).toContain('API URL: https://api.aiconnect.cloud/api/v0');
|
|
571
|
+
expect(result).toContain('Server version: 0.4.2');
|
|
572
|
+
expect(result).toContain('Job types available (2):');
|
|
573
|
+
expect(result).toContain('billing-followup');
|
|
574
|
+
expect(result).toContain('💳');
|
|
575
|
+
expect(result).toContain('— Triage de cobrança');
|
|
576
|
+
});
|
|
577
|
+
it('handles zero job types', () => {
|
|
578
|
+
const result = formatContext({ localConfig, total: 0, jobTypes: [] });
|
|
579
|
+
expect(result).toContain('Job types available (0):');
|
|
580
|
+
expect(result).toContain('(no job types registered for this org)');
|
|
581
|
+
});
|
|
582
|
+
it('renders error line when jobTypesError present', () => {
|
|
583
|
+
const result = formatContext({
|
|
584
|
+
localConfig,
|
|
585
|
+
jobTypesError: 'API Error (500): Internal Server Error'
|
|
586
|
+
});
|
|
587
|
+
expect(result).toContain('Context:');
|
|
588
|
+
expect(result).toContain('Org ID: woba');
|
|
589
|
+
expect(result).toContain('Job types: unavailable (error: API Error (500): Internal Server Error)');
|
|
590
|
+
expect(result).not.toContain('Job types available');
|
|
591
|
+
});
|
|
592
|
+
it('preserves alignment when emoji missing', () => {
|
|
593
|
+
const result = formatContext({
|
|
594
|
+
localConfig,
|
|
595
|
+
total: 2,
|
|
596
|
+
jobTypes: [
|
|
597
|
+
{ id: 'with-emoji', name: 'With Emoji', emoji: '✅' },
|
|
598
|
+
{ id: 'no-emoji', name: 'No Emoji' }
|
|
599
|
+
]
|
|
600
|
+
});
|
|
601
|
+
const lines = result.split('\n');
|
|
602
|
+
const withEmojiLine = lines.find((l) => l.includes('with-emoji'));
|
|
603
|
+
const noEmojiLine = lines.find((l) => l.includes('no-emoji'));
|
|
604
|
+
const idxWith = withEmojiLine.indexOf('With Emoji');
|
|
605
|
+
const idxNo = noEmojiLine.indexOf('No Emoji');
|
|
606
|
+
expect(idxWith).toBe(idxNo);
|
|
607
|
+
expect(result).not.toContain('undefined');
|
|
608
|
+
expect(result).not.toContain('null');
|
|
609
|
+
});
|
|
610
|
+
it('shows truncation hint when total > returned items', () => {
|
|
611
|
+
const jobTypes = Array.from({ length: 100 }, (_, i) => ({
|
|
612
|
+
id: `jt-${i}`,
|
|
613
|
+
name: `JT ${i}`
|
|
614
|
+
}));
|
|
615
|
+
const result = formatContext({ localConfig, total: 247, jobTypes });
|
|
616
|
+
expect(result).toContain('Job types available (247):');
|
|
617
|
+
expect(result).toContain('… and 147 more job types not shown');
|
|
618
|
+
});
|
|
619
|
+
it('produces byte-equal output across calls with identical input', () => {
|
|
620
|
+
const input = {
|
|
621
|
+
localConfig,
|
|
622
|
+
total: 1,
|
|
623
|
+
jobTypes: [{ id: 'a', name: 'A', emoji: '🅰️' }]
|
|
624
|
+
};
|
|
625
|
+
const a = formatContext(input);
|
|
626
|
+
const b = formatContext(input);
|
|
627
|
+
expect(a).toBe(b);
|
|
628
|
+
});
|
|
629
|
+
});
|
package/build/utils/schemas.js
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Factory that returns a fresh Zod schema validating ISO 8601 date-time strings.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Accepts any string parseable by `new Date(value)` (full ISO 8601 with `Z` or
|
|
6
|
+
* timezone offset, and date-only forms like `2024-07-23`).
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT — why this is a factory and not a singleton: when the MCP SDK
|
|
9
|
+
* serializes a tool's input shape to JSON Schema (via `zod-to-json-schema`),
|
|
10
|
+
* reusing the same Zod *instance* across multiple fields causes the serializer
|
|
11
|
+
* to emit `$ref` from sibling fields back to the first one. Many clients/LLMs
|
|
12
|
+
* then treat those fields as `any` and send `null`, which the server rejects.
|
|
13
|
+
* Returning a new instance per call guarantees inline definitions per field.
|
|
14
|
+
*
|
|
15
|
+
* Convention for this project: every reusable Zod schema referenced by more
|
|
16
|
+
* than one tool field MUST be exported as a factory (`() => z.something(...)`),
|
|
17
|
+
* never as a singleton `const`.
|
|
9
18
|
*/
|
|
10
|
-
export const flexibleDateTimeSchema = z.string().refine((value) => {
|
|
11
|
-
// Try to parse the date string.
|
|
12
|
-
// The Date constructor is quite flexible with ISO 8601 formats.
|
|
19
|
+
export const flexibleDateTimeSchema = () => z.string().refine((value) => {
|
|
13
20
|
const date = new Date(value);
|
|
14
|
-
// Check if the parsed date is valid.
|
|
15
|
-
// `isNaN(date.getTime())` is a reliable way to check for invalid dates.
|
|
16
21
|
return !isNaN(date.getTime());
|
|
17
22
|
}, {
|
|
18
|
-
// Custom error message for invalid date-time strings.
|
|
19
23
|
message: "Invalid date-time string. Please use a valid ISO 8601 format.",
|
|
20
24
|
});
|
|
@@ -1,40 +1,95 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
2
4
|
import { flexibleDateTimeSchema } from './schemas.js';
|
|
3
5
|
describe('flexibleDateTimeSchema', () => {
|
|
4
|
-
// Test case 1: Valid ISO 8601 with UTC 'Z'
|
|
5
6
|
it('should validate a correct ISO 8601 string with Zulu time', () => {
|
|
6
|
-
const
|
|
7
|
-
const result = flexibleDateTimeSchema.safeParse(validDate);
|
|
7
|
+
const result = flexibleDateTimeSchema().safeParse('2025-07-23T21:00:00Z');
|
|
8
8
|
expect(result.success).toBe(true);
|
|
9
9
|
});
|
|
10
|
-
// Test case 2: Valid ISO 8601 with a positive timezone offset
|
|
11
10
|
it('should validate a correct ISO 8601 string with a positive offset', () => {
|
|
12
|
-
const
|
|
13
|
-
const result = flexibleDateTimeSchema.safeParse(validDate);
|
|
11
|
+
const result = flexibleDateTimeSchema().safeParse('2025-07-23T22:00:00+01:00');
|
|
14
12
|
expect(result.success).toBe(true);
|
|
15
13
|
});
|
|
16
|
-
// Test case 3: Valid ISO 8601 with a negative timezone offset
|
|
17
14
|
it('should validate a correct ISO 8601 string with a negative offset', () => {
|
|
18
|
-
const
|
|
19
|
-
const result = flexibleDateTimeSchema.safeParse(validDate);
|
|
15
|
+
const result = flexibleDateTimeSchema().safeParse('2025-07-23T16:00:00-05:00');
|
|
20
16
|
expect(result.success).toBe(true);
|
|
21
17
|
});
|
|
22
|
-
// Test case 4: Invalid date format (not ISO 8601)
|
|
23
18
|
it('should not validate an incorrect date format', () => {
|
|
24
|
-
const
|
|
25
|
-
const result = flexibleDateTimeSchema.safeParse(invalidDate);
|
|
19
|
+
const result = flexibleDateTimeSchema().safeParse('23/07/2025 21:00:00');
|
|
26
20
|
expect(result.success).toBe(false);
|
|
27
21
|
});
|
|
28
|
-
// Test case 5: Invalid date string (gibberish)
|
|
29
22
|
it('should not validate a gibberish string', () => {
|
|
30
|
-
const
|
|
31
|
-
const result = flexibleDateTimeSchema.safeParse(gibberish);
|
|
23
|
+
const result = flexibleDateTimeSchema().safeParse('not-a-date');
|
|
32
24
|
expect(result.success).toBe(false);
|
|
33
25
|
});
|
|
34
|
-
// Test case 6: Empty string
|
|
35
26
|
it('should not validate an empty string', () => {
|
|
36
|
-
const
|
|
37
|
-
const result = flexibleDateTimeSchema.safeParse(emptyString);
|
|
27
|
+
const result = flexibleDateTimeSchema().safeParse('');
|
|
38
28
|
expect(result.success).toBe(false);
|
|
39
29
|
});
|
|
30
|
+
it('should validate a date-only string (no time component)', () => {
|
|
31
|
+
const result = flexibleDateTimeSchema().safeParse('2024-07-23');
|
|
32
|
+
expect(result.success).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it('should reject invalid string with the exact ISO 8601 error message', () => {
|
|
35
|
+
const result = flexibleDateTimeSchema().safeParse('not-a-date');
|
|
36
|
+
expect(result.success).toBe(false);
|
|
37
|
+
if (!result.success) {
|
|
38
|
+
expect(result.error.issues[0].message).toBe('Invalid date-time string. Please use a valid ISO 8601 format.');
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
it('should reject null when used with .optional() (string | undefined, not nullable)', () => {
|
|
42
|
+
const schema = flexibleDateTimeSchema().optional();
|
|
43
|
+
expect(schema.safeParse(undefined).success).toBe(true);
|
|
44
|
+
const nullResult = schema.safeParse(null);
|
|
45
|
+
expect(nullResult.success).toBe(false);
|
|
46
|
+
if (!nullResult.success) {
|
|
47
|
+
const issue = nullResult.error.issues[0];
|
|
48
|
+
expect(issue.code).toBe('invalid_type');
|
|
49
|
+
// Zod 3: { expected: 'string', received: 'null' }
|
|
50
|
+
expect(issue.expected).toBe('string');
|
|
51
|
+
expect(issue.received).toBe('null');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('JSON Schema serialization', () => {
|
|
56
|
+
it('should inline each list_jobs date filter field without $ref', () => {
|
|
57
|
+
const shape = z.object({
|
|
58
|
+
scheduled_at: flexibleDateTimeSchema().optional(),
|
|
59
|
+
scheduled_at_gte: flexibleDateTimeSchema().optional(),
|
|
60
|
+
scheduled_at_lte: flexibleDateTimeSchema().optional(),
|
|
61
|
+
created_at_gte: flexibleDateTimeSchema().optional(),
|
|
62
|
+
created_at_lte: flexibleDateTimeSchema().optional(),
|
|
63
|
+
});
|
|
64
|
+
const json = zodToJsonSchema(shape);
|
|
65
|
+
const expectedFields = [
|
|
66
|
+
'scheduled_at',
|
|
67
|
+
'scheduled_at_gte',
|
|
68
|
+
'scheduled_at_lte',
|
|
69
|
+
'created_at_gte',
|
|
70
|
+
'created_at_lte',
|
|
71
|
+
];
|
|
72
|
+
for (const field of expectedFields) {
|
|
73
|
+
const prop = json.properties[field];
|
|
74
|
+
expect(prop.$ref, `expected ${field} to be inline, got $ref ${String(prop.$ref)}`).toBeUndefined();
|
|
75
|
+
expect(prop.type, `expected ${field} to have type 'string', got ${String(prop.type)}`).toBe('string');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
it('should produce $ref-free JSON for both list_jobs and get_jobs_stats shapes', () => {
|
|
79
|
+
const listJobsShape = z.object({
|
|
80
|
+
scheduled_at: flexibleDateTimeSchema().optional(),
|
|
81
|
+
scheduled_at_gte: flexibleDateTimeSchema().optional(),
|
|
82
|
+
scheduled_at_lte: flexibleDateTimeSchema().optional(),
|
|
83
|
+
created_at_gte: flexibleDateTimeSchema().optional(),
|
|
84
|
+
created_at_lte: flexibleDateTimeSchema().optional(),
|
|
85
|
+
});
|
|
86
|
+
const statsShape = z.object({
|
|
87
|
+
scheduled_at_gte: flexibleDateTimeSchema().optional(),
|
|
88
|
+
scheduled_at_lte: flexibleDateTimeSchema().optional(),
|
|
89
|
+
created_at_gte: flexibleDateTimeSchema().optional(),
|
|
90
|
+
created_at_lte: flexibleDateTimeSchema().optional(),
|
|
91
|
+
});
|
|
92
|
+
expect(JSON.stringify(zodToJsonSchema(listJobsShape))).not.toContain('$ref');
|
|
93
|
+
expect(JSON.stringify(zodToJsonSchema(statsShape))).not.toContain('$ref');
|
|
94
|
+
});
|
|
40
95
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
function readVersion() {
|
|
3
|
+
try {
|
|
4
|
+
const raw = fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf-8');
|
|
5
|
+
const pkg = JSON.parse(raw);
|
|
6
|
+
return pkg.version ?? 'unknown';
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return 'unknown';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const mcpServerVersion = readVersion();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mcpServerVersion } from './version.js';
|
|
3
|
+
describe('mcpServerVersion', () => {
|
|
4
|
+
it('resolves to a non-empty string from package.json in dev tree', () => {
|
|
5
|
+
expect(typeof mcpServerVersion).toBe('string');
|
|
6
|
+
expect(mcpServerVersion.length).toBeGreaterThan(0);
|
|
7
|
+
expect(mcpServerVersion).not.toBe('unknown');
|
|
8
|
+
});
|
|
9
|
+
});
|
package/docs/agent-jobs-api.md
CHANGED
|
@@ -239,6 +239,47 @@ Cancels a job by changing its status to CANCELED.
|
|
|
239
239
|
- 404: Job not found
|
|
240
240
|
- 409: Conflict (job is already in a terminal state)
|
|
241
241
|
|
|
242
|
+
### List Job Types (`agent-jobs-type`)
|
|
243
|
+
|
|
244
|
+
Lists agent job types registered for an organization. Used by `get_job_type` and `get_context` in this MCP.
|
|
245
|
+
|
|
246
|
+
#### Path em uso pelas tools deste MCP hoje
|
|
247
|
+
|
|
248
|
+
```http
|
|
249
|
+
GET /organizations/:org_id/agent-jobs-type
|
|
250
|
+
GET /organizations/:org_id/agent-jobs-type/:job_type_id
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
(Sem prefixo `/services/`.) **Toda tool nova neste repo deve usar este caminho** até a unificação descrita abaixo, para manter consistência com `get_job_type`.
|
|
254
|
+
|
|
255
|
+
#### Path canônico upstream
|
|
256
|
+
|
|
257
|
+
```http
|
|
258
|
+
GET /services/organizations/:org_id/agent-jobs-type
|
|
259
|
+
GET /services/organizations/:org_id/agent-jobs-type/:job_type_id
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Este é o caminho documentado upstream pelo AI Connect API. O backend serve a mesma controller em ambos os mounts (legacy + atual). Uma migração futura para o caminho canônico deve ser feita em **change separado abrangendo todas as tools afetadas**: `get_job_type`, `list_jobs`, `create_job`, `get_jobs_stats` e `get_context`.
|
|
263
|
+
|
|
264
|
+
#### Query parameters suportados
|
|
265
|
+
|
|
266
|
+
- `enrich=emoji` — adiciona o campo `emoji` no payload (cache server-side de 1h por job_type).
|
|
267
|
+
- `include=schema` — inclui `params_schema` completo de cada job type (não usado por `get_context`; pesado).
|
|
268
|
+
- `limit` — número máximo de itens por página. `get_context` fixa em `100`.
|
|
269
|
+
- `offset` — paginação numérica.
|
|
270
|
+
- `sort` — formato `field:direction`, ex: `name:asc` (default usado por `get_context`).
|
|
271
|
+
|
|
272
|
+
#### Response shape
|
|
273
|
+
|
|
274
|
+
```json
|
|
275
|
+
{
|
|
276
|
+
"data": [
|
|
277
|
+
{ "id": "string", "name": "string", "description": "string?", "emoji": "string?" }
|
|
278
|
+
],
|
|
279
|
+
"meta": { "total": 0, "limit": 100, "offset": 0 }
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
242
283
|
## Job Status Values
|
|
243
284
|
|
|
244
285
|
Jobs can have the following status values:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiconnect/agentjobs-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "MCP (Model Context Protocol) server for managing Agent Jobs in the AI Connect platform. Developed by AI Connect - Advanced AI automation and integration solutions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
"eslint": "^9.14.0",
|
|
62
62
|
"typescript-eslint": "^8.12.2",
|
|
63
63
|
"typescript": "^5.7.2",
|
|
64
|
-
"vitest": "^3.2.4"
|
|
64
|
+
"vitest": "^3.2.4",
|
|
65
|
+
"zod-to-json-schema": "^3.23.0"
|
|
65
66
|
},
|
|
66
67
|
"engines": {
|
|
67
68
|
"node": ">=18.0.0"
|