@aion0/forge 0.10.47 → 0.10.49
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/RELEASE_NOTES.md +5 -6
- package/app/api/auth/keys/[id]/route.ts +16 -0
- package/app/api/auth/keys/route.ts +36 -0
- package/app/api/mcp/route.ts +144 -0
- package/cli/key.ts +67 -0
- package/cli/mcp-install.ts +106 -0
- package/cli/mcp-proxy.ts +196 -0
- package/cli/mw.mjs +453 -35
- package/cli/mw.ts +26 -1
- package/components/SettingsModal.tsx +123 -0
- package/lib/api-auth.ts +50 -0
- package/lib/api-keys.ts +157 -0
- package/lib/auth/idp-login.ts +25 -3
- package/lib/jobs/store.ts +25 -0
- package/lib/projects.ts +79 -5
- package/lib/settings.ts +12 -2
- package/mcp/README.md +46 -0
- package/mcp/server.ts +30 -0
- package/mcp/tools/_shared.ts +103 -0
- package/mcp/tools/automation.ts +244 -0
- package/mcp/tools/connectors.ts +83 -0
- package/mcp/tools/help.ts +50 -0
- package/mcp/tools/index.ts +39 -0
- package/mcp/tools/integrations.ts +97 -0
- package/mcp/tools/logs.ts +57 -0
- package/mcp/tools/marketplace.ts +75 -0
- package/mcp/tools/observability.ts +96 -0
- package/mcp/tools/pipelines.ts +150 -0
- package/mcp/tools/projects.ts +54 -0
- package/mcp/tools/tasks.ts +93 -0
- package/mcp/tools/workspace.ts +94 -0
- package/package.json +1 -1
- package/proxy.ts +27 -16
- package/src/core/db/database.ts +50 -43
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Shared helpers for management MCP tools. */
|
|
2
|
+
|
|
3
|
+
/** Wrap a string as an MCP text result. */
|
|
4
|
+
export function text(s: string) {
|
|
5
|
+
return { content: [{ type: 'text' as const, text: s }] };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Wrap an error as an MCP text result flagged is_error. */
|
|
9
|
+
export function fail(s: string) {
|
|
10
|
+
return { content: [{ type: 'text' as const, text: s }], isError: true };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Run a handler, converting thrown errors into a clean is_error result so a
|
|
14
|
+
* tool bug never crashes the session. */
|
|
15
|
+
export async function guard(fn: () => Promise<ReturnType<typeof text>> | ReturnType<typeof text>) {
|
|
16
|
+
try { return await fn(); }
|
|
17
|
+
catch (e) { return fail(`Error: ${(e as Error).message}`); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Human label for which session a created task attached to. Reflects what
|
|
21
|
+
* actually happened — an explicit fresh session, a continued existing one, or a
|
|
22
|
+
* fresh start because no prior session was found — rather than over-promising
|
|
23
|
+
* "continuing" when there was nothing to continue. */
|
|
24
|
+
export function describeSession(
|
|
25
|
+
newSession: boolean | undefined,
|
|
26
|
+
conversationId: string | null | undefined,
|
|
27
|
+
): string {
|
|
28
|
+
if (newSession) return 'new session';
|
|
29
|
+
return conversationId ? 'continuing session' : 'no prior session — started fresh';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True if a marketplace skill is installed globally or in any project. A
|
|
33
|
+
* SkillItem exposes `installedGlobal` / `installedProjects` — there is no
|
|
34
|
+
* `installed` field, so reading one silently never matches. */
|
|
35
|
+
export function skillInstalled(e: { installedGlobal?: boolean; installedProjects?: string[] }): boolean {
|
|
36
|
+
return !!(e.installedGlobal || e.installedProjects?.length);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Display status for one connector settings field. Secret-bearing fields NEVER
|
|
40
|
+
* echo their (decrypted-at-rest) value — only whether they are set — so listing a
|
|
41
|
+
* connector can't leak credentials. This mirrors the codebase's canonical secret
|
|
42
|
+
* detection (lib/connectors/registry.ts isSecretField = `secret || password`, and
|
|
43
|
+
* the ConnectorsPanel UI), which masks `secret` AND `password` and treats
|
|
44
|
+
* `instances` arrays as opaque because each row can carry its own sub-secret (e.g.
|
|
45
|
+
* a per-server PAT). Only plain non-secret scalar values are shown (clipped). */
|
|
46
|
+
export function connectorFieldStatus(field: { type: string; required?: boolean }, value: unknown): string {
|
|
47
|
+
const unset = field.required ? 'UNSET (required)' : 'unset';
|
|
48
|
+
// secret / password — report set-ness only, never the value.
|
|
49
|
+
if (field.type === 'secret' || field.type === 'password') return value ? 'set' : unset;
|
|
50
|
+
// instances — opaque: rows may hold sub-secrets, so never serialize the value.
|
|
51
|
+
if (field.type === 'instances') {
|
|
52
|
+
let rows = value;
|
|
53
|
+
if (typeof rows === 'string') { try { rows = JSON.parse(rows); } catch { /* keep as-is */ } }
|
|
54
|
+
const n = Array.isArray(rows) ? rows.length : (value ? 1 : 0);
|
|
55
|
+
return n ? `${n} instance(s) configured` : unset;
|
|
56
|
+
}
|
|
57
|
+
if (value !== undefined && value !== '') return `= ${String(JSON.stringify(value)).slice(0, 40)}`;
|
|
58
|
+
return field.required ? 'MISSING (required)' : 'unset';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Human description of a schedule/job trigger from its kind + timing fields. */
|
|
62
|
+
export function describeTiming(s: {
|
|
63
|
+
schedule_kind?: string | null;
|
|
64
|
+
schedule_interval_minutes?: number;
|
|
65
|
+
schedule_cron?: string | null;
|
|
66
|
+
schedule_at?: string | null;
|
|
67
|
+
}): string {
|
|
68
|
+
switch (s.schedule_kind) {
|
|
69
|
+
case 'cron': return `cron "${s.schedule_cron || '?'}"`;
|
|
70
|
+
case 'once': return `once at ${s.schedule_at || '?'}`;
|
|
71
|
+
case 'manual': return 'manual only';
|
|
72
|
+
case 'period': return `every ${s.schedule_interval_minutes ?? '?'}m`;
|
|
73
|
+
default:
|
|
74
|
+
// Unknown/absent kind: prefer a real trigger field over mislabelling as an interval.
|
|
75
|
+
if (s.schedule_cron) return `cron "${s.schedule_cron}"`;
|
|
76
|
+
if (s.schedule_at) return `once at ${s.schedule_at}`;
|
|
77
|
+
return s.schedule_interval_minutes != null ? `every ${s.schedule_interval_minutes}m` : 'unknown schedule';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Pure pipeline-summary logic for forge_status: count running across ALL runs
|
|
82
|
+
* (not a truncated recent window, which undercounts an older still-running run)
|
|
83
|
+
* and surface the 3 newest. */
|
|
84
|
+
export function summarizePipelines(
|
|
85
|
+
all: { id: string; status: string; workflowName: string; createdAt: string }[],
|
|
86
|
+
): { runningCount: number; recent: string[] } {
|
|
87
|
+
const runningCount = all.filter((p) => p.status === 'running').length;
|
|
88
|
+
const recent = [...all]
|
|
89
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
90
|
+
.slice(0, 3)
|
|
91
|
+
.map((p) => `${p.id} [${p.status}] ${p.workflowName}`);
|
|
92
|
+
return { runningCount, recent };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Compact token/count formatter: 1234567 → "1.2M", 340000 → "340K", 999 → "999".
|
|
96
|
+
* Non-finite input collapses to "0" so a bad row never prints "NaN". */
|
|
97
|
+
export function fmtTokens(n: number): string {
|
|
98
|
+
if (!Number.isFinite(n)) return '0';
|
|
99
|
+
const abs = Math.abs(n);
|
|
100
|
+
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
101
|
+
if (abs >= 1_000) return `${Math.round(n / 1_000)}K`;
|
|
102
|
+
return String(Math.round(n));
|
|
103
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/** Automation tools — Forge's two recurring-work engines:
|
|
2
|
+
* • Schedules: fire ONE body (pipeline or prompt) on a trigger, optional notify.
|
|
3
|
+
* • Jobs: poll a connector, dedup, and dispatch MANY items to pipelines/chat.
|
|
4
|
+
* Schedules have list/create/update/manage; jobs have list/manage. The
|
|
5
|
+
* issue-autofix / watch subsystems are deferred to a later phase. In-process lib
|
|
6
|
+
* calls; run/cancel mirror the route handlers. */
|
|
7
|
+
|
|
8
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import {
|
|
11
|
+
listSchedules, getSchedule, createSchedule, updateSchedule, deleteSchedule,
|
|
12
|
+
listScheduleRuns, listInflightForSchedule,
|
|
13
|
+
} from '@/lib/schedules/store';
|
|
14
|
+
import type { UpdateScheduleInput } from '@/lib/schedules/types';
|
|
15
|
+
import { executeSchedule, isScheduleBusy } from '@/lib/schedules/scheduler';
|
|
16
|
+
import {
|
|
17
|
+
listJobs, getJob, updateJob, deleteJob,
|
|
18
|
+
listRuns, resetDedup, cancelJobDrain, listJobDispatchedPipelines,
|
|
19
|
+
seedNextRunAt as seedJobNextRunAt,
|
|
20
|
+
} from '@/lib/jobs/store';
|
|
21
|
+
import { runJob, isJobBusy } from '@/lib/jobs/scheduler';
|
|
22
|
+
import { cancelPipeline, getWorkflow } from '@/lib/pipeline';
|
|
23
|
+
import { cancelTask } from '@/lib/task-manager';
|
|
24
|
+
import { ensureInitialized } from '@/lib/init';
|
|
25
|
+
import { text, fail, guard, describeTiming } from './_shared';
|
|
26
|
+
|
|
27
|
+
const SCHED_ACTIONS = ['run', 'pause', 'resume', 'cancel', 'delete'] as const;
|
|
28
|
+
const JOB_ACTIONS = ['run', 'pause', 'resume', 'cancel', 'reset_dedup', 'delete'] as const;
|
|
29
|
+
|
|
30
|
+
export function registerAutomationTools(server: McpServer): void {
|
|
31
|
+
// ── Schedules ───────────────────────────────────────────────
|
|
32
|
+
server.tool(
|
|
33
|
+
'forge_list_schedules',
|
|
34
|
+
'List Forge schedules (recurring runs of a pipeline or prompt). Pass id for one schedule\'s detail + recent runs. Use forge_manage_schedule to run/pause/resume/cancel/delete.',
|
|
35
|
+
{ id: z.string().optional().describe('A schedule id for full detail + recent runs') },
|
|
36
|
+
(params) => guard(() => {
|
|
37
|
+
if (params.id) {
|
|
38
|
+
const s = getSchedule(params.id);
|
|
39
|
+
if (!s) return fail(`Schedule not found: ${params.id}`);
|
|
40
|
+
const busy = isScheduleBusy(s.id);
|
|
41
|
+
const lines = [
|
|
42
|
+
`Schedule: ${s.name} (${s.id}) [${s.enabled ? 'enabled' : 'disabled'}${busy.busy ? `, busy: ${busy.reason}` : ''}]`,
|
|
43
|
+
`Body: ${s.body_kind} → ${s.body_ref}`,
|
|
44
|
+
`Trigger: ${describeTiming(s)} · Action: ${s.action_kind}`,
|
|
45
|
+
`Next run: ${s.next_run_at || 'n/a'} · Last run: ${s.last_run_at || 'never'}`,
|
|
46
|
+
];
|
|
47
|
+
const runs = listScheduleRuns(s.id, 5);
|
|
48
|
+
if (runs.length) {
|
|
49
|
+
lines.push('Recent runs:');
|
|
50
|
+
for (const r of runs) lines.push(` ${r.status} ${r.started_at?.slice(0, 16) || ''}${r.error ? ` — ${r.error}` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
return text(lines.join('\n'));
|
|
53
|
+
}
|
|
54
|
+
const all = listSchedules();
|
|
55
|
+
if (!all.length) return text('No schedules.');
|
|
56
|
+
const lines = all.map((s) =>
|
|
57
|
+
`• ${s.name} (${s.id}) [${s.enabled ? 'on' : 'off'}] ${s.body_kind}:${s.body_ref} · ${describeTiming(s)} · next ${s.next_run_at?.slice(0, 16) || 'n/a'}`);
|
|
58
|
+
return text(`${all.length} schedule(s):\n${lines.join('\n')}`);
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
server.tool(
|
|
63
|
+
'forge_manage_schedule',
|
|
64
|
+
'Manage an existing schedule: run (fire now), pause (disable + cancel any in-flight run), resume (enable), cancel (stop in-flight runs — pipeline or prompt-bodied task), or delete.',
|
|
65
|
+
{
|
|
66
|
+
id: z.string().describe('Schedule id (from forge_list_schedules)'),
|
|
67
|
+
action: z.enum(SCHED_ACTIONS).describe('What to do'),
|
|
68
|
+
},
|
|
69
|
+
(params) => guard(async () => {
|
|
70
|
+
const s = getSchedule(params.id);
|
|
71
|
+
if (!s) return fail(`Schedule not found: ${params.id}`);
|
|
72
|
+
const cancelInflight = () => {
|
|
73
|
+
let n = 0;
|
|
74
|
+
for (const r of listInflightForSchedule(s.id)) {
|
|
75
|
+
try {
|
|
76
|
+
// A prompt-bodied run stores target_id = task.id (not a pipeline id),
|
|
77
|
+
// so cancel the right thing by body kind.
|
|
78
|
+
const ok = s.body_kind === 'prompt' ? cancelTask(r.target_id) : cancelPipeline(r.target_id);
|
|
79
|
+
if (ok) n++;
|
|
80
|
+
} catch { /* best-effort */ }
|
|
81
|
+
}
|
|
82
|
+
return n;
|
|
83
|
+
};
|
|
84
|
+
switch (params.action) {
|
|
85
|
+
case 'run': {
|
|
86
|
+
const busy = isScheduleBusy(s.id);
|
|
87
|
+
if (busy.busy) return fail(`Schedule is busy: ${busy.reason}. Cancel it first, or wait.`);
|
|
88
|
+
ensureInitialized();
|
|
89
|
+
const targetId = await executeSchedule(s, 'manual');
|
|
90
|
+
return text(`Fired ${s.name} (manual) → ${s.body_kind} ${targetId}. Track with forge_get_pipeline / forge_get_task id=${targetId}.`);
|
|
91
|
+
}
|
|
92
|
+
case 'pause': { updateSchedule(s.id, { enabled: false }); const n = cancelInflight(); return text(`Paused ${s.name}${n ? ` and cancelled ${n} inflight run(s)` : ''}.`); }
|
|
93
|
+
// resume: updateSchedule reseeds next_run_at on enable (schedules store), so no explicit seed (unlike jobs).
|
|
94
|
+
case 'resume': { updateSchedule(s.id, { enabled: true }); return text(`Resumed ${s.name}.`); }
|
|
95
|
+
case 'cancel': { const n = cancelInflight(); return text(`Cancelled ${n} inflight run(s) for ${s.name}.`); }
|
|
96
|
+
case 'delete': { cancelInflight(); deleteSchedule(s.id); return text(`Deleted ${s.name}.`); }
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
server.tool(
|
|
102
|
+
'forge_upsert_schedule',
|
|
103
|
+
'Create a schedule, or edit an existing one when id is given. CREATE: name + workflow + exactly one trigger (every_minutes / at / cron) are required. EDIT (id set): every field is optional and only what you pass changes (at most one trigger). Optionally notify on completion via action + action_config.',
|
|
104
|
+
{
|
|
105
|
+
id: z.string().optional().describe('Schedule id to EDIT (from forge_list_schedules). Omit to CREATE.'),
|
|
106
|
+
name: z.string().optional().describe('Schedule name (required on create)'),
|
|
107
|
+
workflow: z.string().optional().describe('Pipeline workflow name from forge_list_workflows (required on create)'),
|
|
108
|
+
every_minutes: z.number().int().positive().optional().describe('Interval trigger: run every N minutes'),
|
|
109
|
+
at: z.string().optional().describe('Once trigger: ISO timestamp to fire at (e.g. "2026-07-01T09:00:00Z")'),
|
|
110
|
+
cron: z.string().optional().describe('Cron trigger: a 5-field cron expression (e.g. "0 9 * * *")'),
|
|
111
|
+
input: z.record(z.string(), z.string()).optional().describe('Workflow input variables (replaces existing on edit)'),
|
|
112
|
+
skills: z.array(z.string()).optional().describe('Extra skill names to load for the run'),
|
|
113
|
+
enabled: z.boolean().optional().describe('On create: start enabled (default true). Use forge_manage_schedule to enable/disable later.'),
|
|
114
|
+
action: z.enum(['none', 'chat', 'email', 'telegram']).optional().describe('Notify on completion. Non-none requires action_config.'),
|
|
115
|
+
action_config: z.record(z.string(), z.string()).optional().describe('Config for action — chat: {session_id}; email: {to}; telegram: {chat_id}.'),
|
|
116
|
+
},
|
|
117
|
+
(params) => guard(() => {
|
|
118
|
+
const triggerCount = [params.every_minutes != null, !!params.at, !!params.cron].filter(Boolean).length;
|
|
119
|
+
if (params.action && params.action !== 'none' && !(params.action_config && Object.keys(params.action_config).length)) {
|
|
120
|
+
return fail(`action="${params.action}" needs action_config (chat: session_id; email: to; telegram: chat_id).`);
|
|
121
|
+
}
|
|
122
|
+
const kindFrom = () => params.every_minutes != null ? 'period' as const : params.at ? 'once' as const : 'cron' as const;
|
|
123
|
+
ensureInitialized(); // ensure the scheduler is running so the schedule fires
|
|
124
|
+
|
|
125
|
+
if (params.id) { // ── edit ──
|
|
126
|
+
const s = getSchedule(params.id);
|
|
127
|
+
if (!s) return fail(`Schedule not found: ${params.id}`);
|
|
128
|
+
if (triggerCount > 1) return fail('Provide at most one of every_minutes, at, or cron.');
|
|
129
|
+
const patch: UpdateScheduleInput = {};
|
|
130
|
+
if (params.name !== undefined) patch.name = params.name;
|
|
131
|
+
if (params.input !== undefined) patch.input = params.input;
|
|
132
|
+
if (params.skills !== undefined) patch.skills = params.skills;
|
|
133
|
+
if (params.action !== undefined) patch.action_kind = params.action;
|
|
134
|
+
if (params.action_config !== undefined) patch.action_config = params.action_config;
|
|
135
|
+
if (triggerCount === 1) {
|
|
136
|
+
const kind = kindFrom();
|
|
137
|
+
patch.schedule_kind = kind;
|
|
138
|
+
if (kind === 'period') patch.schedule_interval_minutes = params.every_minutes;
|
|
139
|
+
else if (kind === 'once') patch.schedule_at = params.at;
|
|
140
|
+
else patch.schedule_cron = params.cron;
|
|
141
|
+
}
|
|
142
|
+
if (Object.keys(patch).length === 0) return fail('Nothing to update — provide name, input, skills, action, or a trigger (every_minutes/at/cron).');
|
|
143
|
+
updateSchedule(s.id, patch);
|
|
144
|
+
// updateSchedule reseeds next_run_at internally when the trigger changes.
|
|
145
|
+
const after = getSchedule(s.id)!;
|
|
146
|
+
return text(`Updated ${after.name} (${after.id}) — ${describeTiming(after)}. Next run: ${after.next_run_at || 'n/a'}.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── create ──
|
|
150
|
+
if (!params.name) return fail('name is required to create a schedule (or pass id to edit one).');
|
|
151
|
+
if (!params.workflow) return fail('workflow is required to create a schedule. Use forge_list_workflows.');
|
|
152
|
+
if (triggerCount !== 1) return fail('Provide exactly one of every_minutes, at, or cron.');
|
|
153
|
+
if (!getWorkflow(params.workflow)) return fail(`Workflow not found: ${params.workflow}. Use forge_list_workflows.`);
|
|
154
|
+
const s = createSchedule({
|
|
155
|
+
name: params.name,
|
|
156
|
+
body_kind: 'pipeline',
|
|
157
|
+
body_ref: params.workflow,
|
|
158
|
+
input: params.input,
|
|
159
|
+
skills: params.skills,
|
|
160
|
+
enabled: params.enabled,
|
|
161
|
+
schedule_kind: kindFrom(),
|
|
162
|
+
schedule_interval_minutes: params.every_minutes,
|
|
163
|
+
schedule_at: params.at ?? null,
|
|
164
|
+
schedule_cron: params.cron ?? null,
|
|
165
|
+
action_kind: params.action || 'none',
|
|
166
|
+
action_config: params.action_config,
|
|
167
|
+
});
|
|
168
|
+
return text(`Created schedule ${s.name} (${s.id}) — ${describeTiming(s)} → pipeline ${s.body_ref}. Next run: ${s.next_run_at || 'n/a'}.\nManage with forge_manage_schedule id=${s.id}.`);
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// ── Jobs ────────────────────────────────────────────────────
|
|
173
|
+
server.tool(
|
|
174
|
+
'forge_list_jobs',
|
|
175
|
+
'List Forge jobs (poll a connector on a schedule, dedup results, and dispatch each new item to a pipeline or chat). Pass id for one job\'s detail + recent runs. Use forge_manage_job to run/pause/resume/cancel/reset_dedup/delete.',
|
|
176
|
+
{ id: z.string().optional().describe('A job id for full detail + recent runs') },
|
|
177
|
+
(params) => guard(() => {
|
|
178
|
+
if (params.id) {
|
|
179
|
+
const j = getJob(params.id);
|
|
180
|
+
if (!j) return fail(`Job not found: ${params.id}`);
|
|
181
|
+
const busy = isJobBusy(j.id);
|
|
182
|
+
const wf = j.dispatch_type === 'pipeline' ? ` → ${(j.dispatch_params as { workflow_name?: string }).workflow_name || '?'}` : '';
|
|
183
|
+
const lines = [
|
|
184
|
+
`Job: ${j.name} (${j.id}) [${j.enabled ? 'enabled' : 'disabled'}${busy.busy ? `, busy: ${busy.reason}` : ''}]`,
|
|
185
|
+
`Source: ${j.source_connector}.${j.source_tool} (dedup on "${j.dedup_field}")`,
|
|
186
|
+
`Dispatch: ${j.dispatch_type}${wf} · max ${j.max_per_tick}/tick · ${j.concurrency_mode}`,
|
|
187
|
+
`Trigger: ${describeTiming(j)} · Next run: ${j.next_run_at || 'n/a'} · Last: ${j.last_run_at || 'never'}`,
|
|
188
|
+
];
|
|
189
|
+
const runs = listRuns(j.id, 5);
|
|
190
|
+
if (runs.length) {
|
|
191
|
+
lines.push('Recent runs:');
|
|
192
|
+
for (const r of runs) lines.push(` ${r.status} seen=${r.items_seen} new=${r.items_new} dispatched=${r.items_dispatched} ${r.started_at?.slice(0, 16) || ''}${r.error ? ` — ${r.error}` : ''}`);
|
|
193
|
+
}
|
|
194
|
+
return text(lines.join('\n'));
|
|
195
|
+
}
|
|
196
|
+
const all = listJobs();
|
|
197
|
+
if (!all.length) return text('No jobs.');
|
|
198
|
+
const lines = all.map((j) =>
|
|
199
|
+
`• ${j.name} (${j.id}) [${j.enabled ? 'on' : 'off'}] ${j.source_connector}.${j.source_tool} → ${j.dispatch_type} · ${describeTiming(j)} · next ${j.next_run_at?.slice(0, 16) || 'n/a'}`);
|
|
200
|
+
return text(`${all.length} job(s):\n${lines.join('\n')}`);
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
server.tool(
|
|
205
|
+
'forge_manage_job',
|
|
206
|
+
'Manage an existing job: run (fire now), pause (disable + stop drain), resume (enable), cancel (stop drain + cancel inflight dispatched pipelines), reset_dedup (re-process all items next run), or delete.',
|
|
207
|
+
{
|
|
208
|
+
id: z.string().describe('Job id (from forge_list_jobs)'),
|
|
209
|
+
action: z.enum(JOB_ACTIONS).describe('What to do'),
|
|
210
|
+
},
|
|
211
|
+
(params) => guard(async () => {
|
|
212
|
+
const j = getJob(params.id);
|
|
213
|
+
if (!j) return fail(`Job not found: ${params.id}`);
|
|
214
|
+
switch (params.action) {
|
|
215
|
+
case 'run': {
|
|
216
|
+
const busy = isJobBusy(j.id);
|
|
217
|
+
if (busy.busy) return fail(`Job is busy: ${busy.reason}. Cancel it first, or wait.`);
|
|
218
|
+
ensureInitialized();
|
|
219
|
+
const runId = await runJob(j.id, 'manual');
|
|
220
|
+
return text(`Ran ${j.name} (manual) → run ${runId}. Inspect with forge_list_jobs id=${j.id}.`);
|
|
221
|
+
}
|
|
222
|
+
case 'pause': { updateJob(j.id, { enabled: false }); cancelJobDrain(j.id); return text(`Paused ${j.name} (drain stopped).`); }
|
|
223
|
+
case 'resume': {
|
|
224
|
+
updateJob(j.id, { enabled: true });
|
|
225
|
+
// reseed next_run_at: pause (cancelJobDrain) nulled it, NULL = due now.
|
|
226
|
+
if (j.schedule_kind !== 'manual') seedJobNextRunAt(j.id);
|
|
227
|
+
return text(`Resumed ${j.name}.`);
|
|
228
|
+
}
|
|
229
|
+
case 'cancel': {
|
|
230
|
+
cancelJobDrain(j.id);
|
|
231
|
+
let n = 0;
|
|
232
|
+
for (const p of listJobDispatchedPipelines(j.id, 50)) {
|
|
233
|
+
if (p.pipeline_status === 'running' || p.pipeline_status === 'pending') {
|
|
234
|
+
try { if (cancelPipeline(p.pipeline_id)) n++; } catch { /* best-effort */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return text(`Stopped drain for ${j.name}${n ? `, cancelled ${n} inflight pipeline(s)` : ''}.`);
|
|
238
|
+
}
|
|
239
|
+
case 'reset_dedup': { const c = resetDedup(j.id); return text(`Reset dedup for ${j.name} (${c} key(s) cleared) — next run re-processes all items.`); }
|
|
240
|
+
case 'delete': { cancelJobDrain(j.id); deleteJob(j.id); return text(`Deleted ${j.name}.`); }
|
|
241
|
+
}
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Connector config tools — inspect and configure installed connectors (the
|
|
2
|
+
* external-system tool bundles: GitLab, Jenkins, Mantis, …). Installing comes
|
|
3
|
+
* from the marketplace (forge_marketplace_install); this is the post-install
|
|
4
|
+
* config surface: see settings + status, set settings, enable/disable, test.
|
|
5
|
+
*
|
|
6
|
+
* Secret fields are stored encrypted at rest; this tool masks them on display
|
|
7
|
+
* (only "set"/"unset") and relies on the store to encrypt any secret values it
|
|
8
|
+
* writes. In-process lib calls. */
|
|
9
|
+
|
|
10
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import {
|
|
13
|
+
listInstalledConnectors, getInstalledConnector,
|
|
14
|
+
setConnectorConfig, setConnectorEnabled,
|
|
15
|
+
} from '@/lib/connectors/registry';
|
|
16
|
+
import { runConnectorTest } from '@/lib/connectors/test-runner';
|
|
17
|
+
import { text, fail, guard, connectorFieldStatus } from './_shared';
|
|
18
|
+
|
|
19
|
+
export function registerConnectorTools(server: McpServer): void {
|
|
20
|
+
server.tool(
|
|
21
|
+
'forge_list_connectors',
|
|
22
|
+
'List installed connectors (external-system tool bundles) and their enabled state. Pass id for one connector\'s settings schema + which fields are set/missing (secret values are masked). Configure with forge_configure_connector.',
|
|
23
|
+
{ id: z.string().optional().describe('A connector id for its settings detail') },
|
|
24
|
+
(params) => guard(() => {
|
|
25
|
+
if (params.id) {
|
|
26
|
+
const inst = getInstalledConnector(params.id);
|
|
27
|
+
if (!inst) return fail(`Connector not installed: ${params.id}. Find one with forge_marketplace_search kind="connector".`);
|
|
28
|
+
const def = inst.definition;
|
|
29
|
+
const lines = [
|
|
30
|
+
`Connector: ${def.name} (${def.id}) [${inst.enabled ? 'enabled' : 'disabled'}] v${inst.installed_version}`,
|
|
31
|
+
];
|
|
32
|
+
const fields = Object.entries(def.settings || {});
|
|
33
|
+
if (fields.length) {
|
|
34
|
+
lines.push('Settings:');
|
|
35
|
+
for (const [k, f] of fields) lines.push(` • ${k} [${f.type}${f.required ? ', required' : ''}] ${connectorFieldStatus(f, inst.config[k])}`);
|
|
36
|
+
} else {
|
|
37
|
+
lines.push('Settings: none.');
|
|
38
|
+
}
|
|
39
|
+
lines.push(`Configure: forge_configure_connector id="${def.id}" settings={…} enable=true test=true.`);
|
|
40
|
+
return text(lines.join('\n'));
|
|
41
|
+
}
|
|
42
|
+
const all = listInstalledConnectors();
|
|
43
|
+
if (!all.length) return text('No connectors installed. Find one with forge_marketplace_search kind="connector", then forge_marketplace_install.');
|
|
44
|
+
const lines = all.map((c) => `• ${c.definition.id} (${c.definition.name}) [${c.enabled ? 'enabled' : 'disabled'}] v${c.installed_version}`);
|
|
45
|
+
return text(`${all.length} connector(s):\n${lines.join('\n')}\n\nDetail: forge_list_connectors id="…".`);
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
server.tool(
|
|
50
|
+
'forge_configure_connector',
|
|
51
|
+
'Configure an installed connector: set one or more settings (secret fields are encrypted at rest), enable/disable it, and/or run its connection test. Provide at least one of settings, enable, or test.',
|
|
52
|
+
{
|
|
53
|
+
id: z.string().describe('Connector id (from forge_list_connectors)'),
|
|
54
|
+
settings: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
|
|
55
|
+
.describe('Field → value to set (merged over current config). See the schema via forge_list_connectors id=…'),
|
|
56
|
+
enable: z.boolean().optional().describe('Enable (true) or disable (false) the connector'),
|
|
57
|
+
test: z.boolean().optional().describe('Run the connector\'s connection test after applying changes'),
|
|
58
|
+
},
|
|
59
|
+
(params) => guard(async () => {
|
|
60
|
+
const inst = getInstalledConnector(params.id);
|
|
61
|
+
if (!inst) return fail(`Connector not installed: ${params.id}.`);
|
|
62
|
+
const hasSettings = params.settings && Object.keys(params.settings).length > 0;
|
|
63
|
+
if (!hasSettings && params.enable === undefined && !params.test) {
|
|
64
|
+
return fail('Nothing to do — provide settings, enable, and/or test.');
|
|
65
|
+
}
|
|
66
|
+
const done: string[] = [];
|
|
67
|
+
if (hasSettings) {
|
|
68
|
+
setConnectorConfig(params.id, { ...inst.config, ...params.settings });
|
|
69
|
+
done.push(`updated ${Object.keys(params.settings!).join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
if (params.enable !== undefined) {
|
|
72
|
+
setConnectorEnabled(params.id, params.enable);
|
|
73
|
+
done.push(params.enable ? 'enabled' : 'disabled');
|
|
74
|
+
}
|
|
75
|
+
let testLine = '';
|
|
76
|
+
if (params.test) {
|
|
77
|
+
const r = await runConnectorTest(params.id);
|
|
78
|
+
testLine = `\nTest: ${r.ok ? 'OK' : `FAILED — ${r.error || 'unknown error'}`}`;
|
|
79
|
+
}
|
|
80
|
+
return text(`${inst.definition.name}: ${done.join('; ') || 'no changes'}.${testLine}`);
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Help tool — answers conceptual questions ("what's a pipeline?", "how do I
|
|
2
|
+
* install Forge?") from the bundled help docs, so the client can explain Forge
|
|
3
|
+
* without the user opening the UI. Reads lib/help-docs (synced to <dataDir>/help). */
|
|
4
|
+
|
|
5
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { getDataDir } from '@/lib/dirs';
|
|
10
|
+
import { text, fail, guard } from './_shared';
|
|
11
|
+
|
|
12
|
+
/** Resolve the help-docs directory: the synced runtime copy, else the repo source. */
|
|
13
|
+
function helpDir(): string | null {
|
|
14
|
+
const synced = join(getDataDir(), 'help');
|
|
15
|
+
if (existsSync(synced)) return synced;
|
|
16
|
+
const src = join(process.cwd(), 'lib', 'help-docs');
|
|
17
|
+
if (existsSync(src)) return src;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** "05-pipelines.md" → "pipelines" */
|
|
22
|
+
const slug = (file: string) => file.replace(/\.md$/, '').replace(/^\d+-/, '');
|
|
23
|
+
|
|
24
|
+
export function registerHelpTools(server: McpServer): void {
|
|
25
|
+
server.tool(
|
|
26
|
+
'forge_help',
|
|
27
|
+
'Explain a Forge concept or feature from the official docs (e.g. topic "pipelines", "connectors", "tasks", "workspace", "overview" for install/setup). Call with no topic to list available topics.',
|
|
28
|
+
{ topic: z.string().optional().describe('Doc topic, e.g. "pipelines", "install", "connectors"') },
|
|
29
|
+
(params) => guard(() => {
|
|
30
|
+
const dir = helpDir();
|
|
31
|
+
if (!dir) return fail('Help docs not found.');
|
|
32
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'CLAUDE.md');
|
|
33
|
+
if (!params.topic) {
|
|
34
|
+
const topics = files.map(slug).sort();
|
|
35
|
+
return text(`Available help topics:\n${topics.map((t) => `• ${t}`).join('\n')}\n\nCall forge_help with one of these, e.g. topic="pipelines".`);
|
|
36
|
+
}
|
|
37
|
+
const q = params.topic.toLowerCase().replace(/[\s-]+/g, '');
|
|
38
|
+
const aliases: Record<string, string> = { install: 'overview', setup: 'overview', model: 'settings', agent: 'settings', marketplace: 'skills' };
|
|
39
|
+
const wanted = aliases[q] || q;
|
|
40
|
+
const match = files.find((f) => slug(f).replace(/-/g, '').includes(wanted))
|
|
41
|
+
|| files.find((f) => slug(f).replace(/-/g, '').includes(q));
|
|
42
|
+
if (!match) {
|
|
43
|
+
return fail(`No help topic matches "${params.topic}". Try: ${files.map(slug).join(', ')}.`);
|
|
44
|
+
}
|
|
45
|
+
const body = readFileSync(join(dir, match), 'utf-8');
|
|
46
|
+
const clipped = body.length > 6000 ? body.slice(0, 6000) + '\n\n…(truncated)' : body;
|
|
47
|
+
return text(clipped);
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry for the Forge Management MCP.
|
|
3
|
+
*
|
|
4
|
+
* Each tool is an INTENT (not a 1:1 REST passthrough). Handlers call `lib/`
|
|
5
|
+
* functions in-process — never SQL from the client, never self-HTTP. The one
|
|
6
|
+
* exception is workspace mutations, which go through the workspace daemon HTTP
|
|
7
|
+
* API per the single-writer rule (see workspace.ts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
+
import type { ManagementContext } from '../server';
|
|
12
|
+
import { registerProjectTools } from './projects';
|
|
13
|
+
import { registerTaskTools } from './tasks';
|
|
14
|
+
import { registerLogTools } from './logs';
|
|
15
|
+
import { registerIntegrationTools } from './integrations';
|
|
16
|
+
import { registerHelpTools } from './help';
|
|
17
|
+
import { registerPipelineTools } from './pipelines';
|
|
18
|
+
import { registerMarketplaceTools } from './marketplace';
|
|
19
|
+
import { registerWorkspaceTools } from './workspace';
|
|
20
|
+
import { registerObservabilityTools } from './observability';
|
|
21
|
+
import { registerAutomationTools } from './automation';
|
|
22
|
+
import { registerConnectorTools } from './connectors';
|
|
23
|
+
|
|
24
|
+
export function registerTools(server: McpServer, _ctx: ManagementContext): void {
|
|
25
|
+
registerProjectTools(server); // forge_list_projects, forge_register_project, forge_unregister_project
|
|
26
|
+
registerTaskTools(server); // forge_{list,get,create,cancel}_task(s)
|
|
27
|
+
registerLogTools(server); // forge_tail_logs
|
|
28
|
+
registerIntegrationTools(server); // forge_list_agents, forge_set_default_agent, forge_set_agent_model
|
|
29
|
+
registerHelpTools(server); // forge_help
|
|
30
|
+
registerPipelineTools(server); // forge_{list_workflows,run_pipeline,get_pipeline,cancel_pipeline,list_pipeline_runs,create_pipeline}
|
|
31
|
+
registerMarketplaceTools(server); // forge_marketplace_search, forge_marketplace_install
|
|
32
|
+
registerWorkspaceTools(server); // forge_list_workspace_agents, forge_add_workspace_agent
|
|
33
|
+
registerObservabilityTools(server); // forge_status, forge_get_usage
|
|
34
|
+
registerAutomationTools(server); // forge_{list,upsert,manage}_schedule, forge_{list,manage}_job
|
|
35
|
+
registerConnectorTools(server); // forge_list_connectors, forge_configure_connector
|
|
36
|
+
|
|
37
|
+
// ── TODO v1b (needs additive endpoints) ──
|
|
38
|
+
// forge_stream_logs (SSE) · forge_setup_key
|
|
39
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Integration tools — the CLI coding agents Forge wraps (claude code, codex,
|
|
2
|
+
* aider …): which are installed, which is the default, and per-component model
|
|
3
|
+
* overrides. "Is Claude integrated? Set it as default. Use different models for
|
|
4
|
+
* different components."
|
|
5
|
+
*
|
|
6
|
+
* NOTE: these are CLI *agents* (settings.agents / defaultAgent), distinct from
|
|
7
|
+
* workspace agents/smiths (see workspace.ts). Settings writes go load → mutate →
|
|
8
|
+
* saveSettings, then clearAgentCache() so resolvers don't serve stale adapters. */
|
|
9
|
+
|
|
10
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { listAgents, getDefaultAgentId, clearAgentCache } from '@/lib/agents';
|
|
13
|
+
import type { AgentConfig } from '@/lib/agents/types';
|
|
14
|
+
import { loadSettings, saveSettings } from '@/lib/settings';
|
|
15
|
+
import { text, fail, guard } from './_shared';
|
|
16
|
+
|
|
17
|
+
const SCENES = ['terminal', 'task', 'telegram', 'help', 'mobile'] as const;
|
|
18
|
+
|
|
19
|
+
/** Fuzzy-resolve a user-typed agent name ("claude", "claude code", "Codex") to a
|
|
20
|
+
* real agent. Matches id, then cliType/name (whitespace/hyphen-insensitive). */
|
|
21
|
+
function resolveAgent(input: string): AgentConfig | undefined {
|
|
22
|
+
const agents = listAgents();
|
|
23
|
+
const norm = (s: string) => s.toLowerCase().replace(/[\s-]+/g, '');
|
|
24
|
+
const q = norm(input);
|
|
25
|
+
return agents.find((a) => a.id.toLowerCase() === input.toLowerCase())
|
|
26
|
+
|| agents.find((a) => norm(a.id) === q)
|
|
27
|
+
|| agents.find((a) => norm(a.cliType || '') === q || norm(a.name) === q)
|
|
28
|
+
|| agents.find((a) => norm(a.name).includes(q) || norm(a.cliType || '').includes(q));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The settings.agents `tool` discriminator for a (possibly built-in) agent. */
|
|
32
|
+
function toolFor(a: AgentConfig): 'claude' | 'codex' | 'aider' | 'opencode' {
|
|
33
|
+
if (['claude', 'codex', 'aider', 'opencode'].includes(a.id)) return a.id as 'claude';
|
|
34
|
+
if (a.cliType === 'claude-code') return 'claude';
|
|
35
|
+
if (a.cliType === 'codex') return 'codex';
|
|
36
|
+
if (a.cliType === 'aider') return 'aider';
|
|
37
|
+
return 'claude';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerIntegrationTools(server: McpServer): void {
|
|
41
|
+
server.tool(
|
|
42
|
+
'forge_list_agents',
|
|
43
|
+
'List the CLI coding agents Forge can drive (claude code, codex, aider, and any custom profiles): whether each is installed and enabled, which is the current default, and any per-component model overrides. Use this to answer "is Claude integrated?".',
|
|
44
|
+
{},
|
|
45
|
+
() => guard(() => {
|
|
46
|
+
const agents = listAgents();
|
|
47
|
+
const def = getDefaultAgentId();
|
|
48
|
+
const settings = loadSettings();
|
|
49
|
+
const lines = agents.map((a) => {
|
|
50
|
+
const installed = a.path ? `installed` : 'NOT installed';
|
|
51
|
+
const models = settings.agents?.[a.id]?.models || {};
|
|
52
|
+
const modelStr = Object.entries(models).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join(', ');
|
|
53
|
+
return `• ${a.id} (${a.name}) [${a.cliType || a.type}] — ${installed}, ${a.enabled ? 'enabled' : 'disabled'}${a.id === def ? ' ⭐ DEFAULT' : ''}`
|
|
54
|
+
+ (modelStr ? `\n models: ${modelStr}` : '');
|
|
55
|
+
});
|
|
56
|
+
return text(`CLI agents (default = ${def}):\n${lines.join('\n')}\n\nSet the default with forge_set_default_agent; set a component's model with forge_set_agent_model.`);
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
server.tool(
|
|
61
|
+
'forge_set_default_agent',
|
|
62
|
+
'Set the default CLI coding agent Forge uses for tasks and pipelines (e.g. "claude" / "claude code", "codex"). Accepts a fuzzy name and validates it exists.',
|
|
63
|
+
{ agent: z.string().describe('Agent name or id, e.g. "claude", "claude code", "codex"') },
|
|
64
|
+
(params) => guard(() => {
|
|
65
|
+
const match = resolveAgent(params.agent);
|
|
66
|
+
if (!match) return fail(`No agent matches "${params.agent}". Available: ${listAgents().map((a) => a.id).join(', ')}.`);
|
|
67
|
+
const s = loadSettings();
|
|
68
|
+
s.defaultAgent = match.id;
|
|
69
|
+
saveSettings(s);
|
|
70
|
+
clearAgentCache();
|
|
71
|
+
const warn = match.path ? '' : `\n(Note: ${match.id} is not currently installed — install it for tasks to run.)`;
|
|
72
|
+
return text(`Default agent set to ${match.id} (${match.name}).${warn}`);
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
server.tool(
|
|
77
|
+
'forge_set_agent_model',
|
|
78
|
+
'Set the model an agent uses for a specific component/scene — this is how Forge runs different models for different components. Scenes: task, terminal, telegram, help, mobile. Use model "default" to clear an override.',
|
|
79
|
+
{
|
|
80
|
+
agent: z.string().describe('Agent name or id (e.g. "claude")'),
|
|
81
|
+
scene: z.enum(SCENES).describe('Which component the model applies to'),
|
|
82
|
+
model: z.string().describe('Model id (e.g. "claude-opus-4-8"), or "default" to clear'),
|
|
83
|
+
},
|
|
84
|
+
(params) => guard(() => {
|
|
85
|
+
const match = resolveAgent(params.agent);
|
|
86
|
+
if (!match) return fail(`No agent matches "${params.agent}". Available: ${listAgents().map((a) => a.id).join(', ')}.`);
|
|
87
|
+
const s = loadSettings();
|
|
88
|
+
const entry = s.agents?.[match.id] ? { ...s.agents[match.id] } : { tool: toolFor(match) };
|
|
89
|
+
if (!entry.tool) entry.tool = toolFor(match);
|
|
90
|
+
entry.models = { ...(entry.models || {}), [params.scene]: params.model };
|
|
91
|
+
s.agents = { ...(s.agents || {}), [match.id]: entry };
|
|
92
|
+
saveSettings(s);
|
|
93
|
+
clearAgentCache();
|
|
94
|
+
return text(`Set ${match.id} ${params.scene} model to "${params.model}".`);
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
}
|