@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.
@@ -0,0 +1,57 @@
1
+ /** Log tools — read the Forge server log so a user can ask "what's up with
2
+ * Forge?" without opening the UI. Reads <dataDir>/forge.log directly.
3
+ * Real-time streaming is a v1b TODO (needs an SSE endpoint). */
4
+
5
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { z } from 'zod';
7
+ import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { getDataDir } from '@/lib/dirs';
10
+ import { text, guard } from './_shared';
11
+
12
+ /** Read the last `maxBytes` of a file, dropping a partial first line. */
13
+ function tailBytes(file: string, maxBytes: number): string {
14
+ const size = statSync(file).size;
15
+ const readSize = Math.min(size, maxBytes);
16
+ const buf = Buffer.alloc(readSize);
17
+ const fd = openSync(file, 'r');
18
+ try { readSync(fd, buf, 0, readSize, size - readSize); }
19
+ finally { closeSync(fd); }
20
+ const str = buf.toString('utf-8');
21
+ const nl = str.indexOf('\n');
22
+ return nl > 0 && readSize < size ? str.slice(nl + 1) : str;
23
+ }
24
+
25
+ export function registerLogTools(server: McpServer): void {
26
+ server.tool(
27
+ 'forge_tail_logs',
28
+ 'Show the most recent Forge server log lines (the next-server process log). Optionally filter by a search term. Use this to diagnose "what is Forge doing / why did X fail".',
29
+ {
30
+ lines: z.number().int().positive().max(1000).optional().describe('How many recent lines to show (default 100, max 1000)'),
31
+ search: z.string().optional().describe('Only show lines containing this text (case-insensitive)'),
32
+ },
33
+ (params) => guard(() => {
34
+ const file = join(getDataDir(), 'forge.log');
35
+ if (!existsSync(file)) {
36
+ // forge.log is the next-server's redirected stdout — written when Forge is
37
+ // started via `forge server start` / `start.sh`, but NOT by a bare `next dev`
38
+ // (which logs to its own terminal). Point the caller at the records that DO
39
+ // carry per-step errors instead of dead-ending on "no log file".
40
+ return text(
41
+ `No server log file at ${file}.\n`
42
+ + `It's written only when Forge is started via \`forge server start\` or \`start.sh\` — a bare \`next dev\` logs to its own stdout/terminal.\n`
43
+ + `For why a task or pipeline failed, the error is on the record itself: forge_list_tasks status="failed", then forge_get_task id=…, or forge_get_pipeline id=… for per-node errors.`,
44
+ );
45
+ }
46
+ const n = params.lines ?? 100;
47
+ let all = tailBytes(file, 512 * 1024).split('\n').filter(Boolean);
48
+ if (params.search) {
49
+ const q = params.search.toLowerCase();
50
+ all = all.filter((l) => l.toLowerCase().includes(q));
51
+ }
52
+ const shown = all.slice(-n);
53
+ if (shown.length === 0) return text(params.search ? `No log lines match "${params.search}".` : 'Log is empty.');
54
+ return text(`Last ${shown.length} line(s)${params.search ? ` matching "${params.search}"` : ''} of ${file}:\n\n${shown.join('\n')}`);
55
+ }),
56
+ );
57
+ }
@@ -0,0 +1,75 @@
1
+ /** Marketplace tools — search and install Forge extensions: connectors (tool
2
+ * bundles for GitLab/Jenkins/…), skills, and workflow templates. All in-process
3
+ * lib calls; each follows sync → list → install. Listing auto-syncs once if the
4
+ * local cache is empty. */
5
+
6
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import { z } from 'zod';
8
+ import { listMarketplace as listConnectorMarket, syncRegistry, installFromRegistry } from '@/lib/connectors/sync';
9
+ import { listSkills, syncSkills, installGlobal } from '@/lib/skills';
10
+ import { listMarketplace as listWorkflowMarket, syncMarketplace, installFromMarketplace } from '@/lib/workflow-marketplace';
11
+ import { text, fail, guard, skillInstalled } from './_shared';
12
+
13
+ const KIND = ['connector', 'skill', 'workflow'] as const;
14
+
15
+ function filterBy(query: string | undefined, hay: string): boolean {
16
+ return !query || hay.toLowerCase().includes(query.toLowerCase());
17
+ }
18
+
19
+ export function registerMarketplaceTools(server: McpServer): void {
20
+ server.tool(
21
+ 'forge_marketplace_search',
22
+ 'Search the Forge marketplace for extensions to install. kind="connector" (external-system tool bundles), "skill" (agent skills), or "workflow" (pipeline templates). Auto-syncs the catalog once if needed.',
23
+ {
24
+ kind: z.enum(KIND).describe('What to search for'),
25
+ query: z.string().optional().describe('Filter by name/description substring'),
26
+ },
27
+ (params) => guard(async () => {
28
+ if (params.kind === 'connector') {
29
+ let m = listConnectorMarket();
30
+ if (!m.entries?.length) { await syncRegistry(); m = listConnectorMarket(); }
31
+ const items = m.entries.filter((e) => filterBy(params.query, `${e.id} ${e.name} ${e.description || ''}`)).slice(0, 40);
32
+ if (!items.length) return text('No matching connectors (catalog may need a sync, or none match).');
33
+ return text(`Connectors:\n${items.map((e) => `• ${e.id} v${e.version}${e.installed_version ? ' (installed)' : ''} — ${e.description || e.name}`).join('\n')}\n\nInstall with forge_marketplace_install kind="connector" name="<id>".`);
34
+ }
35
+ if (params.kind === 'skill') {
36
+ let s = listSkills();
37
+ if (!s?.length) { await syncSkills(); s = listSkills(); }
38
+ const items = s.filter((e) => filterBy(params.query, `${e.name} ${e.description || ''}`)).slice(0, 40);
39
+ if (!items.length) return text('No matching skills.');
40
+ return text(`Skills:\n${items.map((e) => `• ${e.name}${skillInstalled(e) ? ' (installed)' : ''} — ${e.description || ''}`).join('\n')}\n\nInstall with forge_marketplace_install kind="skill" name="<name>".`);
41
+ }
42
+ // workflow
43
+ let w = listWorkflowMarket();
44
+ if (!w.recipes?.length && !w.pipelines?.length) { await syncMarketplace(); w = listWorkflowMarket(); }
45
+ const all = [
46
+ ...(w.pipelines || []).map((e) => ({ ...e, _kind: 'pipeline' })),
47
+ ...(w.recipes || []).map((e) => ({ ...e, _kind: 'recipe' })),
48
+ ].filter((e) => filterBy(params.query, `${e.name} ${e.description || ''}`)).slice(0, 40);
49
+ if (!all.length) return text('No matching workflow templates.');
50
+ return text(`Workflow templates:\n${all.map((e) => `• [${e._kind}] ${e.name} — ${e.description || ''}`).join('\n')}\n\nInstall with forge_marketplace_install kind="workflow" name="<name>" workflow_kind="<pipeline|recipe>".`);
51
+ }),
52
+ );
53
+
54
+ server.tool(
55
+ 'forge_marketplace_install',
56
+ 'Install a marketplace extension by kind and name (see forge_marketplace_search). For workflows, pass workflow_kind ("pipeline" or "recipe").',
57
+ {
58
+ kind: z.enum(KIND).describe('connector | skill | workflow'),
59
+ name: z.string().describe('The id/name from forge_marketplace_search'),
60
+ workflow_kind: z.enum(['pipeline', 'recipe']).optional().describe('For kind="workflow": which list it came from (default pipeline)'),
61
+ },
62
+ (params) => guard(async () => {
63
+ if (params.kind === 'connector') {
64
+ const r = await installFromRegistry(params.name);
65
+ return r.ok ? text(`Installed connector ${params.name}${r.version ? ` v${r.version}` : ''}. Configure it in Settings → Connectors.`) : fail(`Install failed: ${r.error || 'unknown error'}`);
66
+ }
67
+ if (params.kind === 'skill') {
68
+ await installGlobal(params.name); // throws on failure
69
+ return text(`Installed skill "${params.name}" globally.`);
70
+ }
71
+ const r = await installFromMarketplace(params.workflow_kind || 'pipeline', params.name);
72
+ return r.ok ? text(`Installed workflow "${params.name}". List it with forge_list_workflows.`) : fail(`Install failed: ${r.error || 'unknown error'}`);
73
+ }),
74
+ );
75
+ }
@@ -0,0 +1,96 @@
1
+ /** Observability tools — "what is Forge doing, and what is it costing?". A live
2
+ * status snapshot (tasks/pipelines/schedules/tools) and token/cost analytics.
3
+ * All in-process lib reads; no mutations. */
4
+
5
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { z } from 'zod';
7
+ import { listTasksLite } from '@/lib/task-manager';
8
+ import { listPipelines } from '@/lib/pipeline';
9
+ import { listSchedules } from '@/lib/schedules/store';
10
+ import { checkTools } from '@/lib/health';
11
+ import { queryUsage } from '@/lib/usage-scanner';
12
+ import { text, guard, fmtTokens, summarizePipelines } from './_shared';
13
+
14
+ const TASK_STATES = ['running', 'queued', 'done', 'failed', 'cancelled'] as const;
15
+
16
+ // checkTools() spawns blocking `which`/`--version` subprocesses per CLI tool, so
17
+ // memoize it behind a short TTL — forge_status shouldn't reprobe the PATH on every
18
+ // call. The set of installed tools changes rarely. Stored on a globalThis Symbol so
19
+ // the cache survives HMR / multiple module evaluations (same pattern as
20
+ // app/api/mcp/route.ts), instead of resetting on every reload.
21
+ type ToolsCache = { at: number; tools: ReturnType<typeof checkTools> };
22
+ const TOOLS_CACHE_KEY = Symbol.for('forge-mcp-tools-cache');
23
+ const _g = globalThis as unknown as Record<symbol, ToolsCache | undefined>;
24
+ function checkToolsCached(): ReturnType<typeof checkTools> {
25
+ const now = Date.now();
26
+ const cached = _g[TOOLS_CACHE_KEY];
27
+ if (cached && now - cached.at < 60_000) return cached.tools;
28
+ const fresh: ToolsCache = { at: now, tools: checkTools() };
29
+ _g[TOOLS_CACHE_KEY] = fresh;
30
+ return fresh.tools;
31
+ }
32
+
33
+ export function registerObservabilityTools(server: McpServer): void {
34
+ server.tool(
35
+ 'forge_status',
36
+ 'A snapshot of what Forge is doing right now: background-task counts by status, in-flight and recent pipelines, schedules, and which CLI tools are installed. Use this to answer "is Forge healthy / what is running?".',
37
+ {},
38
+ () => guard(() => {
39
+ const tasks = listTasksLite();
40
+ const counts: Record<string, number> = {};
41
+ for (const t of tasks) counts[t.status] = (counts[t.status] || 0) + 1;
42
+ const taskLine = TASK_STATES.filter((s) => counts[s]).map((s) => `${counts[s]} ${s}`).join(', ') || 'none';
43
+
44
+ const { runningCount, recent } = summarizePipelines(listPipelines());
45
+
46
+ const schedules = listSchedules();
47
+ const enabled = schedules.filter((s) => s.enabled).length;
48
+
49
+ const tools = checkToolsCached();
50
+ const installed = tools.filter((t) => t.installed).map((t) => t.name);
51
+ const missing = tools.filter((t) => !t.installed).map((t) => t.name);
52
+
53
+ const lines = [
54
+ `Tasks: ${taskLine} (${tasks.length} total)`,
55
+ `Pipelines: ${runningCount} running${recent.length ? ` · recent: ${recent.join(' · ')}` : ''}`,
56
+ `Schedules: ${schedules.length} (${enabled} enabled)`,
57
+ `Tools installed: ${installed.join(', ') || 'none'}${missing.length ? ` · missing: ${missing.join(', ')}` : ''}`,
58
+ ];
59
+ return text(`Forge status:\n${lines.join('\n')}`);
60
+ }),
61
+ );
62
+
63
+ server.tool(
64
+ 'forge_get_usage',
65
+ 'Token usage and cost analytics over a recent window, optionally filtered by project or model. Reads the usage store, which Forge rescans automatically about hourly, so figures may be up to an hour old.',
66
+ {
67
+ days: z.number().int().positive().max(365).optional().describe('Window in days (default 30)'),
68
+ project: z.string().optional().describe('Filter to one project name'),
69
+ model: z.string().optional().describe('Filter to one model family as shown in the "By model" output (e.g. claude-opus-4, claude-sonnet-4, claude-haiku-4). Usage is aggregated by family — full model ids do not match.'),
70
+ },
71
+ (params) => guard(() => {
72
+ const days = params.days ?? 30;
73
+ const d = queryUsage({ days, projectName: params.project, model: params.model });
74
+ const t = d.total;
75
+ const scope = `last ${days} day(s)${params.project ? `, project=${params.project}` : ''}${params.model ? `, model=${params.model}` : ''}`;
76
+ if (!t.messages && !t.cost) {
77
+ return text(`No usage recorded (${scope}). Usage is rescanned automatically about hourly.`);
78
+ }
79
+ const lines = [
80
+ `Usage (${scope}):`,
81
+ `Total: $${t.cost.toFixed(2)} · ${fmtTokens(t.input)} in / ${fmtTokens(t.output)} out · ${t.sessions} sessions · ${t.messages} messages`,
82
+ ];
83
+ const topProjects = [...d.byProject].sort((a, b) => b.cost - a.cost).slice(0, 5);
84
+ if (topProjects.length) {
85
+ lines.push('By project:');
86
+ for (const p of topProjects) lines.push(` • ${p.name} $${p.cost.toFixed(2)} (${p.sessions} sessions)`);
87
+ }
88
+ const topModels = [...d.byModel].sort((a, b) => b.cost - a.cost).slice(0, 5);
89
+ if (topModels.length) {
90
+ lines.push('By model:');
91
+ for (const m of topModels) lines.push(` • ${m.model} $${m.cost.toFixed(2)}`);
92
+ }
93
+ return text(lines.join('\n'));
94
+ }),
95
+ );
96
+ }
@@ -0,0 +1,150 @@
1
+ /** Pipeline tools — list/run/inspect workflow definitions and runs, and create a
2
+ * new workflow from a high-level step list (binding each step to a CLI agent).
3
+ *
4
+ * Vocabulary: a *workflow* is a YAML DAG definition; a *pipeline* is a run of one.
5
+ * Both definitions live in <dataDir>/flows/*.yaml. lib/flows.ts is a separate
6
+ * legacy fan-out system and is intentionally NOT used here. */
7
+
8
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { z } from 'zod';
10
+ import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import YAML from 'yaml';
13
+ import {
14
+ listWorkflows, getWorkflow, startPipeline, getPipeline, listPipelinesSummary, parseWorkflow, cancelPipeline,
15
+ } from '@/lib/pipeline';
16
+ import { getDataDir } from '@/lib/dirs';
17
+ import { ensureInitialized } from '@/lib/init';
18
+ import { getProjectInfo } from '@/lib/projects';
19
+ import { listAgents } from '@/lib/agents';
20
+ import { text, fail, guard } from './_shared';
21
+
22
+ export function registerPipelineTools(server: McpServer): void {
23
+ server.tool(
24
+ 'forge_list_workflows',
25
+ 'List the available pipeline workflow definitions (reusable DAG templates). Use forge_run_pipeline to start one, or forge_help topic="pipelines" to learn what a pipeline is.',
26
+ {},
27
+ () => guard(() => {
28
+ const wfs = listWorkflows();
29
+ if (wfs.length === 0) return text('No workflows defined. Create one with forge_create_pipeline, or install from the marketplace.');
30
+ const lines = wfs.map((w) => {
31
+ const n = Object.keys(w.nodes || {}).length;
32
+ return `• ${w.name}${w.type && w.type !== 'dag' ? ` (${w.type})` : ''} — ${n} node(s)${w.description ? `: ${w.description}` : ''}`;
33
+ });
34
+ return text(`${wfs.length} workflow(s):\n${lines.join('\n')}`);
35
+ }),
36
+ );
37
+
38
+ server.tool(
39
+ 'forge_run_pipeline',
40
+ 'Start a pipeline from a workflow definition. Provide any input variables the workflow declares.',
41
+ {
42
+ workflow: z.string().describe('Workflow name (from forge_list_workflows)'),
43
+ input: z.record(z.string(), z.string()).optional().describe('Input variables, e.g. { "project": "my-app" }'),
44
+ },
45
+ (params) => guard(() => {
46
+ if (!getWorkflow(params.workflow)) return fail(`Workflow not found: ${params.workflow}. Use forge_list_workflows.`);
47
+ ensureInitialized();
48
+ const pipeline = startPipeline(params.workflow, params.input || {});
49
+ return text(`Started pipeline ${pipeline.id} (workflow: ${params.workflow}, status: ${pipeline.status}).\nTrack it with forge_get_pipeline id=${pipeline.id}.`);
50
+ }),
51
+ );
52
+
53
+ server.tool(
54
+ 'forge_get_pipeline',
55
+ 'Show the status and per-node results of a pipeline run.',
56
+ { id: z.string().describe('Pipeline run id') },
57
+ (params) => guard(() => {
58
+ const p = getPipeline(params.id);
59
+ if (!p) return fail(`Pipeline not found: ${params.id}`);
60
+ const nodes = Object.entries(p.nodes).map(([id, n]) => {
61
+ let line = ` ${id}: ${n.status}`;
62
+ if (n.error) line += ` — ${n.error}`;
63
+ return line;
64
+ });
65
+ return text(`Pipeline ${p.id} [${p.status}] (workflow: ${p.workflowName})\n${nodes.join('\n')}`);
66
+ }),
67
+ );
68
+
69
+ server.tool(
70
+ 'forge_cancel_pipeline',
71
+ 'Cancel a running pipeline by id — stops its in-flight nodes. No-op if it already finished.',
72
+ { id: z.string().describe('Pipeline run id (from forge_run_pipeline / forge_list_pipeline_runs)') },
73
+ (params) => guard(() => {
74
+ const ok = cancelPipeline(params.id);
75
+ return text(ok ? `Cancelled pipeline ${params.id}.` : `Could not cancel ${params.id} (not found, or already finished).`);
76
+ }),
77
+ );
78
+
79
+ server.tool(
80
+ 'forge_list_pipeline_runs',
81
+ 'List recent pipeline runs (newest first) with their status.',
82
+ { limit: z.number().int().positive().max(100).optional().describe('Max runs to show (default 20)') },
83
+ (params) => guard(() => {
84
+ const runs = listPipelinesSummary({ limit: params.limit ?? 20 });
85
+ if (runs.length === 0) return text('No pipeline runs yet.');
86
+ const lines = runs.map((r) => `• ${r.id} [${r.status}] ${r.workflowName} (${r.createdAt})`);
87
+ return text(`${runs.length} run(s):\n${lines.join('\n')}`);
88
+ }),
89
+ );
90
+
91
+ server.tool(
92
+ 'forge_create_pipeline',
93
+ 'Create a new pipeline workflow from an ordered list of steps. Each step runs an agent in a project; steps run in sequence (each depends on the previous). Bind a step to a specific CLI agent via its "agent" field (defaults to "claude"). Saves a reusable workflow you can then forge_run_pipeline.',
94
+ {
95
+ name: z.string().describe('Workflow name (used as the file name)'),
96
+ description: z.string().optional().describe('What the pipeline does'),
97
+ steps: z.array(z.object({
98
+ project: z.string().describe('Project name or {{vars}} template the step runs in'),
99
+ prompt: z.string().describe('What the agent should do in this step'),
100
+ agent: z.string().optional().describe('CLI agent id for this step (default "claude")'),
101
+ })).min(1).describe('Ordered steps; each runs after the previous one'),
102
+ },
103
+ (params) => guard(() => {
104
+ // Catch typo'd project names at create time rather than letting every node
105
+ // fail at run with "Project not found". Templated ({{var}}) projects are
106
+ // resolved per-run, so they can't be checked here — skip them.
107
+ const unknown = [...new Set(
108
+ params.steps.map((s) => s.project).filter((p) => !p.includes('{{') && !getProjectInfo(p)),
109
+ )];
110
+ if (unknown.length) {
111
+ return fail(`Unknown project(s): ${unknown.join(', ')}. Use forge_list_projects, or pass a {{var}} template resolved at run time.`);
112
+ }
113
+
114
+ // Validate explicit agent ids too, so a typo fails at create time rather than
115
+ // at the node that uses it. (Omitted agent defaults to "claude" below.)
116
+ const validAgents = new Set(listAgents().map((a) => a.id));
117
+ const badAgents = [...new Set(
118
+ params.steps.map((s) => s.agent).filter((a): a is string => !!a && !a.includes('{{') && !validAgents.has(a)),
119
+ )];
120
+ if (badAgents.length) {
121
+ return fail(`Unknown agent id(s): ${badAgents.join(', ')}. Use forge_list_agents, or omit to default to "claude".`);
122
+ }
123
+
124
+ const safeName = params.name.replace(/[^a-zA-Z0-9_-]/g, '-');
125
+ const nodes: Record<string, unknown> = {};
126
+ params.steps.forEach((st, i) => {
127
+ const id = `s${i + 1}`;
128
+ nodes[id] = {
129
+ project: st.project,
130
+ prompt: st.prompt,
131
+ agent: st.agent || 'claude',
132
+ ...(i > 0 ? { depends_on: [`s${i}`] } : {}),
133
+ };
134
+ });
135
+ const wf = { name: safeName, ...(params.description ? { description: params.description } : {}), nodes };
136
+ const yaml = YAML.stringify(wf);
137
+
138
+ // Validate against the engine's own parser before writing.
139
+ try { parseWorkflow(yaml); }
140
+ catch (e) { return fail(`Generated workflow is invalid: ${(e as Error).message}`); }
141
+
142
+ const dir = join(getDataDir(), 'flows');
143
+ mkdirSync(dir, { recursive: true });
144
+ const file = join(dir, `${safeName}.yaml`);
145
+ if (existsSync(file)) return fail(`A workflow named "${safeName}" already exists. Pick another name.`);
146
+ writeFileSync(file, yaml, 'utf-8');
147
+ return text(`Created workflow "${safeName}" with ${params.steps.length} step(s).\nRun it with forge_run_pipeline workflow="${safeName}".`);
148
+ }),
149
+ );
150
+ }
@@ -0,0 +1,54 @@
1
+ /** Project tools — discover, register, and inspect the projects Forge manages.
2
+ * Management-only: metadata (roots, git, language, rules-presence, sessions),
3
+ * never project file contents. All in-process lib calls. */
4
+
5
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { z } from 'zod';
7
+ import { scanProjects, addProjectRoot, removeProjectRoot, SCRATCH_PROJECT_NAME } from '@/lib/projects';
8
+ import { text, fail, guard } from './_shared';
9
+
10
+ export function registerProjectTools(server: McpServer): void {
11
+ server.tool(
12
+ 'forge_list_projects',
13
+ 'List the projects Forge can run tasks against (auto-discovered from the configured project roots). Each line shows name, language, whether it is a git repo, whether it has agent rules (CLAUDE.md), and last-modified date. Use a name with forge_create_task or forge_get_project. If none are registered, use forge_register_project.',
14
+ {},
15
+ () => guard(() => {
16
+ const projects = scanProjects();
17
+ const real = projects.filter((p) => p.name !== SCRATCH_PROJECT_NAME);
18
+ if (real.length === 0) {
19
+ return text('No projects registered yet (only the built-in "scratch" project exists). Register a folder that contains your projects with forge_register_project path="/path/to/code".');
20
+ }
21
+ const lines = projects.map((p) =>
22
+ `• ${p.name}${p.language ? ` [${p.language}]` : ''}${p.hasGit ? ' (git)' : ''}${p.hasClaudeMd ? ' (rules)' : ''}`
23
+ + ` — ${p.path}${p.lastModified ? ` · ${p.lastModified.slice(0, 10)}` : ''}`,
24
+ );
25
+ return text(`${projects.length} project(s):\n${lines.join('\n')}`);
26
+ }),
27
+ );
28
+
29
+ server.tool(
30
+ 'forge_register_project',
31
+ 'Register a project root with Forge: a directory whose immediate subdirectories Forge will list as projects (e.g. "/Users/me/code", which contains your repos). Idempotent; validates the path exists. This is how you make new projects visible to forge_list_projects / forge_create_task.',
32
+ { path: z.string().min(1).describe('Absolute path to a folder that CONTAINS your projects (not a single repo)') },
33
+ (params) => guard(() => {
34
+ const r = addProjectRoot(params.path);
35
+ if (!r.ok) return fail(`Could not register "${params.path}": ${r.error}.`);
36
+ if (r.alreadyRegistered) return text(`Already registered: ${r.root}.`);
37
+ const added = scanProjects().filter((p) => p.root === r.root).map((p) => p.name);
38
+ const found = added.length ? `\nProjects now visible from it: ${added.join(', ')}.` : '\n(No subdirectories found yet — add projects under it, then forge_list_projects.)';
39
+ return text(`Registered project root ${r.root}.${found}`);
40
+ }),
41
+ );
42
+
43
+ server.tool(
44
+ 'forge_unregister_project',
45
+ 'Unregister a project root (does NOT delete files — only stops Forge from listing its subdirectories as projects). Pass the root path exactly as shown in forge_get_project (root: …).',
46
+ { path: z.string().min(1).describe('Absolute path of the root to unregister') },
47
+ (params) => guard(() => {
48
+ const r = removeProjectRoot(params.path);
49
+ if (!r.ok) return fail(`Could not unregister "${params.path}": ${r.error}.`);
50
+ return text(`Unregistered project root ${r.root}. Its subdirectories no longer appear in forge_list_projects.`);
51
+ }),
52
+ );
53
+
54
+ }
@@ -0,0 +1,93 @@
1
+ /** Task tools — the background-task lifecycle (list / inspect / create / cancel /
2
+ * delete). Calls lib/task-manager in-process. */
3
+
4
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { z } from 'zod';
6
+ import {
7
+ listTasksLite, getTask, createTask, cancelTask,
8
+ } from '@/lib/task-manager';
9
+ import { getProjectInfo } from '@/lib/projects';
10
+ import { ensureInitialized } from '@/lib/init';
11
+ import type { TaskStatus } from '@/src/types';
12
+ import { text, fail, guard, describeSession } from './_shared';
13
+
14
+ const STATUS = ['queued', 'running', 'done', 'failed', 'cancelled'] as const;
15
+ const ICON: Record<string, string> = { queued: '⏳', running: '🔄', done: '✅', failed: '❌', cancelled: '⚪' };
16
+
17
+ export function registerTaskTools(server: McpServer): void {
18
+ server.tool(
19
+ 'forge_list_tasks',
20
+ 'List background tasks, newest first. Optionally filter by status. Returns id, status, project, prompt, and cost — use forge_get_task for full detail.',
21
+ { status: z.enum(STATUS).optional().describe('Filter by status') },
22
+ (params) => guard(() => {
23
+ const tasks = listTasksLite(params.status as TaskStatus | undefined);
24
+ if (tasks.length === 0) return text(params.status ? `No ${params.status} tasks.` : 'No tasks.');
25
+ const lines = tasks.slice(0, 50).map((t) => {
26
+ const cost = t.costUSD != null ? ` $${t.costUSD.toFixed(3)}` : '';
27
+ return `${ICON[t.status] || '?'} ${t.id} ${t.status.padEnd(9)} ${t.projectName} ${t.prompt.slice(0, 60)}${cost}`;
28
+ });
29
+ const more = tasks.length > 50 ? `\n… ${tasks.length - 50} more` : '';
30
+ return text(`${tasks.length} task(s):\n${lines.join('\n')}${more}`);
31
+ }),
32
+ );
33
+
34
+ server.tool(
35
+ 'forge_get_task',
36
+ 'Get full detail for one task: status, prompt, cost, result summary, error, and git branch.',
37
+ { id: z.string().describe('Task id') },
38
+ (params) => guard(() => {
39
+ const t = getTask(params.id);
40
+ if (!t) return fail(`Task not found: ${params.id}`);
41
+ const lines = [
42
+ `${ICON[t.status] || '?'} Task ${t.id} [${t.status}]`,
43
+ `Project: ${t.projectName} (${t.projectPath})`,
44
+ `Prompt: ${t.prompt}`,
45
+ ];
46
+ if (t.agent) lines.push(`Agent: ${t.agent}`);
47
+ if (t.gitBranch) lines.push(`Branch: ${t.gitBranch}`);
48
+ if (t.costUSD != null) lines.push(`Cost: $${t.costUSD.toFixed(4)}`);
49
+ if (t.startedAt) lines.push(`Started: ${t.startedAt}`);
50
+ if (t.completedAt) lines.push(`Completed: ${t.completedAt}`);
51
+ if (t.error) lines.push(`\nError: ${t.error}`);
52
+ if (t.resultSummary) lines.push(`\nResult:\n${t.resultSummary.slice(0, 1500)}`);
53
+ return text(lines.join('\n'));
54
+ }),
55
+ );
56
+
57
+ server.tool(
58
+ 'forge_create_task',
59
+ 'Submit a background task to run an agent in a project. Resolves the project by name (see forge_list_projects). By default it continues the project\'s existing session; set new_session to start fresh.',
60
+ {
61
+ project: z.string().describe('Project name (from forge_list_projects)'),
62
+ prompt: z.string().describe('What the agent should do'),
63
+ new_session: z.boolean().optional().describe('Start a fresh session instead of continuing the project\'s last one'),
64
+ agent: z.string().optional().describe('Agent/CLI to use (e.g. "claude", "codex"); defaults to the configured agent'),
65
+ },
66
+ (params) => guard(() => {
67
+ const project = getProjectInfo(params.project);
68
+ if (!project) return fail(`Project not found: ${params.project}. Use forge_list_projects to see valid names.`);
69
+ ensureInitialized(); // make sure the task runner is up so the task actually executes
70
+ const task = createTask({
71
+ projectName: project.name,
72
+ projectPath: project.path,
73
+ prompt: params.prompt,
74
+ priority: 0,
75
+ conversationId: params.new_session ? '' : undefined,
76
+ mode: 'prompt',
77
+ agent: params.agent || undefined,
78
+ });
79
+ const session = describeSession(params.new_session, task.conversationId);
80
+ return text(`Created task ${task.id} in ${task.projectName} (${session}).\nPrompt: ${params.prompt}\nInspect with forge_get_task id=${task.id}.`);
81
+ }),
82
+ );
83
+
84
+ server.tool(
85
+ 'forge_cancel_task',
86
+ 'Cancel a queued or running task. Does not delete it — its record and partial output remain.',
87
+ { id: z.string().describe('Task id') },
88
+ (params) => guard(() => {
89
+ const ok = cancelTask(params.id);
90
+ return text(ok ? `Cancelled task ${params.id}.` : `Could not cancel ${params.id} (not found, or already finished).`);
91
+ }),
92
+ );
93
+ }
@@ -0,0 +1,94 @@
1
+ /** Workspace-agent tools — add/list the agents ("smiths") in a project's
2
+ * multi-agent workspace (e.g. add a code reviewer). These are DISTINCT from CLI
3
+ * agents (see integrations.ts).
4
+ *
5
+ * Per the single-writer rule, workspace mutations go through the workspace daemon
6
+ * HTTP API (never direct state.json writes). Reads use in-process lib. */
7
+
8
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { z } from 'zod';
10
+ import { getProjectInfo } from '@/lib/projects';
11
+ import { findWorkspaceByProject } from '@/lib/workspace';
12
+ import { createFromPreset, AGENT_PRESETS } from '@/lib/workspace/presets';
13
+ import { text, fail, guard } from './_shared';
14
+
15
+ const DAEMON = `http://localhost:${Number(process.env.WORKSPACE_PORT) || 8405}`;
16
+
17
+ /** Map a natural-language role to a preset key (presets: pm, architect, engineer, qa, reviewer, lead). */
18
+ function resolveRole(input: string): string | undefined {
19
+ const q = input.toLowerCase().replace(/[\s_-]+/g, '');
20
+ const alias: Record<string, string> = {
21
+ codereviewer: 'reviewer', reviewer: 'reviewer', review: 'reviewer',
22
+ engineer: 'engineer', dev: 'engineer', developer: 'engineer', coder: 'engineer',
23
+ qa: 'qa', tester: 'qa', test: 'qa',
24
+ pm: 'pm', productmanager: 'pm', product: 'pm',
25
+ architect: 'architect', arch: 'architect',
26
+ lead: 'lead', techlead: 'lead',
27
+ };
28
+ const key = alias[q] || q;
29
+ return AGENT_PRESETS[key] ? key : undefined;
30
+ }
31
+
32
+ async function daemon(path: string, body: unknown): Promise<{ ok: boolean; data?: any; error?: string }> {
33
+ try {
34
+ const res = await fetch(`${DAEMON}${path}`, {
35
+ method: 'POST',
36
+ headers: { 'content-type': 'application/json' },
37
+ body: JSON.stringify(body),
38
+ });
39
+ const data = await res.json().catch(() => ({}));
40
+ if (!res.ok) return { ok: false, error: data?.error || `daemon returned ${res.status}` };
41
+ return { ok: true, data };
42
+ } catch (e) {
43
+ return { ok: false, error: `workspace daemon unreachable at ${DAEMON} (${(e as Error).message}). Is Forge fully started?` };
44
+ }
45
+ }
46
+
47
+ export function registerWorkspaceTools(server: McpServer): void {
48
+ server.tool(
49
+ 'forge_list_workspace_agents',
50
+ 'List the agents (smiths) in a project\'s multi-agent workspace and their roles.',
51
+ { project: z.string().describe('Project name') },
52
+ (params) => guard(() => {
53
+ const project = getProjectInfo(params.project);
54
+ if (!project) return fail(`Project not found: ${params.project}. Use forge_list_projects.`);
55
+ const ws = findWorkspaceByProject(project.path);
56
+ if (!ws) return text(`No workspace exists for ${project.name} yet. Add an agent with forge_add_workspace_agent to create one.`);
57
+ const agents = (ws.agents || []).filter((a) => a.type !== 'input');
58
+ if (agents.length === 0) return text(`${project.name}'s workspace has no agents yet.`);
59
+ const lines = agents.map((a) => `• ${a.label} (${a.role ? a.role.slice(0, 60) : a.backend})${a.primary ? ' [primary]' : ''}`);
60
+ return text(`${agents.length} workspace agent(s) in ${project.name}:\n${lines.join('\n')}`);
61
+ }),
62
+ );
63
+
64
+ server.tool(
65
+ 'forge_add_workspace_agent',
66
+ 'Add an agent to a project\'s multi-agent workspace by role (e.g. "code reviewer", "engineer", "qa", "architect", "pm", "lead"). Creates the workspace if needed. Goes through the workspace daemon.',
67
+ {
68
+ project: z.string().describe('Project name (from forge_list_projects)'),
69
+ role: z.string().describe('Role to add, e.g. "code reviewer", "engineer", "qa"'),
70
+ },
71
+ (params) => guard(async () => {
72
+ const project = getProjectInfo(params.project);
73
+ if (!project) return fail(`Project not found: ${params.project}. Use forge_list_projects.`);
74
+ const roleKey = resolveRole(params.role);
75
+ if (!roleKey) return fail(`Unknown role "${params.role}". Available: ${Object.keys(AGENT_PRESETS).join(', ')}.`);
76
+
77
+ // Find or create the workspace (create goes through the daemon — single writer).
78
+ // findWorkspaceByProject is typed (WorkspaceState.id); the daemon response is
79
+ // untrusted HTTP, so narrow it to the one field we use rather than `any`.
80
+ let wsId = findWorkspaceByProject(project.path)?.id;
81
+ if (!wsId) {
82
+ const created = await daemon('/workspace/create', { projectPath: project.path, projectName: project.name });
83
+ if (!created.ok) return fail(created.error || 'Could not create workspace.');
84
+ wsId = (created.data as { id?: string } | undefined)?.id;
85
+ }
86
+ if (!wsId) return fail('Could not resolve workspace id.');
87
+
88
+ const config = createFromPreset(roleKey);
89
+ const added = await daemon(`/workspace/${wsId}/agents`, { action: 'add', config });
90
+ if (!added.ok) return fail(added.error || 'Could not add agent.');
91
+ return text(`Added ${config.label} (${roleKey}) to ${project.name}'s workspace.`);
92
+ }),
93
+ );
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.47",
3
+ "version": "0.10.49",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {