agent-planner-mcp 1.0.0 → 1.1.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/README.md CHANGED
@@ -123,6 +123,28 @@ Beyond title, description, agent_instructions, and acceptance criteria, the gene
123
123
 
124
124
  The CLI is intentionally thin: it covers the read context + writeback loop and nothing else. For decomposition, dependency creation, knowledge graph queries, RPI chains, coherence runs, and goal management, use the MCP server (or the API directly).
125
125
 
126
+ ## Agent Loop Facade
127
+
128
+ AgentPlanner API now exposes a narrow `/agent/*` facade for the main autonomous loop. MCP uses this facade when available and falls back to older domain endpoints for self-hosted older APIs.
129
+
130
+ Primary mappings:
131
+
132
+ | MCP tool | Preferred API endpoint |
133
+ |---|---|
134
+ | `briefing` | `GET /agent/briefing` |
135
+ | `claim_next_task` | `POST /agent/work-sessions` |
136
+ | `update_task` with `session_id` + `completed` | `POST /agent/work-sessions/:id/complete` |
137
+ | `update_task` with `session_id` + `blocked` | `POST /agent/work-sessions/:id/block` |
138
+ | `form_intention` | `POST /agent/intentions` when available, with domain-endpoint fallback |
139
+
140
+ Validation:
141
+
142
+ ```bash
143
+ npm run validate:mcp-loop
144
+ ```
145
+
146
+ This checks that the MCP tools route through the facade for briefing, task claim/start, and session completion/blocking.
147
+
126
148
 
127
149
  ### Claude Desktop
128
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-planner-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for AgentPlanner — AI agent orchestration with planning, dependencies, knowledge graphs, and human oversight",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "dev": "nodemon src/index.js",
13
13
  "dev:http": "MCP_TRANSPORT=http nodemon src/index.js",
14
14
  "test": "jest",
15
+ "validate:mcp-loop": "jest __tests__/agent-loop-facade.test.js --runInBand",
15
16
  "test:tools": "node test-tools.js",
16
17
  "setup": "node src/setup.js",
17
18
  "setup-claude-code": "node src/setup-claude-code.js",
package/src/api-client.js CHANGED
@@ -17,12 +17,29 @@ const getAuthScheme = (token) => {
17
17
 
18
18
  const authScheme = getAuthScheme(userApiToken);
19
19
 
20
+ // Identify this caller in the API server's tool_calls telemetry.
21
+ // Setup wizards (per client) should set MCP_CLIENT_LABEL to one of
22
+ // "Claude Desktop" | "Claude Code" | "Cursor" | "ChatGPT" | "OpenClaw"
23
+ // so the Settings → Integrations dashboard can group calls per client.
24
+ let pkgVersion;
25
+ try { pkgVersion = require('../package.json').version; } catch { pkgVersion = '0.0.0'; }
26
+ const clientLabel = process.env.MCP_CLIENT_LABEL || null;
27
+ const userAgent = clientLabel
28
+ ? `agent-planner-mcp/${pkgVersion} (${clientLabel})`
29
+ : `agent-planner-mcp/${pkgVersion}`;
30
+
20
31
  // Create API client instance
21
32
  const apiClient = axios.create({
22
33
  baseURL: process.env.API_URL || 'http://localhost:3000',
23
34
  headers: {
24
35
  'Content-Type': 'application/json',
25
- 'Authorization': userApiToken ? `${authScheme} ${userApiToken}` : undefined
36
+ 'Authorization': userApiToken ? `${authScheme} ${userApiToken}` : undefined,
37
+ 'User-Agent': userAgent,
38
+ // X-Client-Label takes precedence on the API side (see
39
+ // toolCallTelemetry.middleware) so MCP setup wizards can label
40
+ // calls precisely without polluting the User-Agent string.
41
+ ...(clientLabel ? { 'X-Client-Label': clientLabel } : {}),
42
+ 'X-MCP-Client': clientLabel || 'unknown',
26
43
  }
27
44
  });
28
45
 
@@ -738,6 +755,15 @@ const users = {
738
755
  },
739
756
  };
740
757
 
758
+ // ─── Agent Loop facade ────────────────────────────────────────
759
+ const agentLoop = {
760
+ briefing: async (params = {}) => (await apiClient.get('/agent/briefing', { params })).data,
761
+ startWorkSession: async (data = {}) => (await apiClient.post('/agent/work-sessions', data)).data,
762
+ completeWorkSession: async (sessionId, data = {}) => (await apiClient.post(`/agent/work-sessions/${sessionId}/complete`, data)).data,
763
+ blockWorkSession: async (sessionId, data = {}) => (await apiClient.post(`/agent/work-sessions/${sessionId}/block`, data)).data,
764
+ createIntention: async (data = {}) => (await apiClient.post('/agent/intentions', data)).data,
765
+ };
766
+
741
767
  // ─── Dependencies (cross-plan & external) ─────────────────────
742
768
  const dependencies = {
743
769
  /**
@@ -778,11 +804,21 @@ const dependencies = {
778
804
  */
779
805
  function createApiClient(token, options = {}) {
780
806
  const scheme = getAuthScheme(token);
807
+ // Telemetry headers — same shape as the default client. options.clientLabel
808
+ // wins over the global env var so HTTP-mode sessions can label themselves
809
+ // per connection (e.g. when the SSE handshake reveals the client).
810
+ const sessionLabel = options.clientLabel || process.env.MCP_CLIENT_LABEL || null;
811
+ const sessionUserAgent = sessionLabel
812
+ ? `agent-planner-mcp/${pkgVersion} (${sessionLabel})`
813
+ : `agent-planner-mcp/${pkgVersion}`;
781
814
  const client = axios.create({
782
815
  baseURL: options.apiUrl || process.env.API_URL || 'http://localhost:3000',
783
816
  headers: {
784
817
  'Content-Type': 'application/json',
785
- 'Authorization': token ? `${scheme} ${token}` : undefined
818
+ 'Authorization': token ? `${scheme} ${token}` : undefined,
819
+ 'User-Agent': sessionUserAgent,
820
+ ...(sessionLabel ? { 'X-Client-Label': sessionLabel } : {}),
821
+ 'X-MCP-Client': sessionLabel || 'unknown',
786
822
  }
787
823
  });
788
824
 
@@ -945,6 +981,13 @@ function createApiClient(token, options = {}) {
945
981
  return (await client.get(`/users/my-tasks${qs}`)).data;
946
982
  },
947
983
  },
984
+ agentLoop: {
985
+ briefing: async (params = {}) => (await client.get('/agent/briefing', { params })).data,
986
+ startWorkSession: async (data = {}) => (await client.post('/agent/work-sessions', data)).data,
987
+ completeWorkSession: async (sessionId, data = {}) => (await client.post(`/agent/work-sessions/${sessionId}/complete`, data)).data,
988
+ blockWorkSession: async (sessionId, data = {}) => (await client.post(`/agent/work-sessions/${sessionId}/block`, data)).data,
989
+ createIntention: async (data = {}) => (await client.post('/agent/intentions', data)).data,
990
+ },
948
991
  axiosInstance: client,
949
992
  };
950
993
  }
@@ -975,6 +1018,7 @@ module.exports = {
975
1018
  dependencies,
976
1019
  coherence,
977
1020
  users,
1021
+ agentLoop,
978
1022
  axiosInstance, // Export for direct API calls
979
1023
  createApiClient // Factory for per-session clients (HTTP mode)
980
1024
  };
package/src/setup.js CHANGED
@@ -158,13 +158,16 @@ function updateClaudeConfig(configPath, mcpServerPath, apiUrl, token) {
158
158
  config.mcpServers = {};
159
159
  }
160
160
 
161
- // Add or update planning-system server
161
+ // Add or update planning-system server.
162
+ // MCP_CLIENT_LABEL identifies this install in the Settings →
163
+ // Integrations dashboard's tool_calls telemetry stream.
162
164
  config.mcpServers['planning-system'] = {
163
165
  command: 'node',
164
166
  args: [path.join(mcpServerPath, 'src', 'index.js')],
165
167
  env: {
166
168
  API_URL: apiUrl,
167
- USER_API_TOKEN: token
169
+ USER_API_TOKEN: token,
170
+ MCP_CLIENT_LABEL: 'Claude Desktop'
168
171
  }
169
172
  };
170
173
 
@@ -30,6 +30,20 @@ const briefingDefinition = {
30
30
  };
31
31
 
32
32
  async function briefingHandler(args, apiClient) {
33
+ try {
34
+ const response = await apiClient.axiosInstance.get('/agent/briefing', {
35
+ params: {
36
+ scope: args.scope,
37
+ goal_id: args.goal_id,
38
+ plan_id: args.plan_id,
39
+ recent_window_hours: args.recent_window_hours,
40
+ },
41
+ });
42
+ return formatResponse(response.data);
43
+ } catch {
44
+ // Fall back to the pre-facade fan-out for self-hosted older APIs.
45
+ }
46
+
33
47
  const recentHours = typeof args.recent_window_hours === 'number' ? args.recent_window_hours : 24;
34
48
  const recentSinceMs = Date.now() - recentHours * 3600 * 1000;
35
49
 
@@ -252,6 +266,19 @@ async function goalStateHandler(args, apiClient) {
252
266
  direct_downstream_count: t.direct_downstream_count || 0,
253
267
  }));
254
268
 
269
+ // Surface the goal's linked plans + tasks. The underlying GET /goals/:id
270
+ // already returns the `links` array; the previous handler discarded it,
271
+ // so quality.actionability could report "26 plans linked" while the
272
+ // response refused to name a single one. Callers had no read-side way
273
+ // to enumerate the plans served by a goal short of REST.
274
+ const links = safeArray(goal.links);
275
+ const linked_plans = links
276
+ .filter((l) => (l.linkedType || l.linked_type) === 'plan')
277
+ .map((l) => ({ id: l.linkedId || l.linked_id, link_id: l.id }));
278
+ const linked_tasks = links
279
+ .filter((l) => (l.linkedType || l.linked_type) === 'task')
280
+ .map((l) => ({ id: l.linkedId || l.linked_id, link_id: l.id }));
281
+
255
282
  return formatResponse({
256
283
  as_of: asOf(),
257
284
  goal: {
@@ -261,6 +288,8 @@ async function goalStateHandler(args, apiClient) {
261
288
  owner_id: goal.ownerId || goal.owner_id, success_criteria: goal.successCriteria || goal.success_criteria,
262
289
  promoted_at: goal.promotedAt || goal.promoted_at,
263
290
  },
291
+ linked_plans,
292
+ linked_tasks,
264
293
  quality: {
265
294
  score: quality.score, dimensions: quality.dimensions,
266
295
  suggestions: quality.suggestions, last_assessed_at: quality.as_of,
@@ -348,6 +377,79 @@ async function recallKnowledgeHandler(args, apiClient) {
348
377
  return formatResponse(out);
349
378
  }
350
379
 
380
+ // ─────────────────────────────────────────────────────────────────────────
381
+ // list_plans — list workspace plans with optional filters.
382
+ // Counterpart to list_goals; previously the only ways to find a plan
383
+ // were knowing the UUID a priori, parsing briefing.recent_activity,
384
+ // or calling task_context on a known node — all bad.
385
+ // ─────────────────────────────────────────────────────────────────────────
386
+
387
+ const listPlansDefinition = {
388
+ name: 'list_plans',
389
+ description:
390
+ 'List plans with optional filters by status, visibility, or text query. ' +
391
+ 'Returns id, title, status, visibility, last update, and link counts so ' +
392
+ 'you can pick a plan to operate on without round-tripping briefing.',
393
+ inputSchema: {
394
+ type: 'object',
395
+ properties: {
396
+ filter: {
397
+ type: 'object',
398
+ properties: {
399
+ status: { type: 'array', items: { type: 'string' } },
400
+ visibility: { type: 'array', items: { type: 'string', enum: ['private', 'unlisted', 'public'] } },
401
+ query: { type: 'string', description: 'Substring match on title (case-insensitive)' },
402
+ limit: { type: 'integer', default: 50 },
403
+ },
404
+ },
405
+ },
406
+ },
407
+ };
408
+
409
+ async function listPlansHandler(args, apiClient) {
410
+ const filter = args.filter || {};
411
+ try {
412
+ const raw = await apiClient.plans.getPlans();
413
+ let plans = Array.isArray(raw) ? raw : safeArray(raw.plans || raw);
414
+
415
+ if (filter.status?.length) plans = plans.filter((p) => filter.status.includes(p.status));
416
+ if (filter.visibility?.length) plans = plans.filter((p) => filter.visibility.includes(p.visibility));
417
+ if (filter.query) {
418
+ const q = filter.query.toLowerCase();
419
+ plans = plans.filter((p) => (p.title || '').toLowerCase().includes(q));
420
+ }
421
+ plans = plans.slice(0, filter.limit || 50);
422
+
423
+ const summary = plans.reduce(
424
+ (acc, p) => {
425
+ acc[p.status] = (acc[p.status] || 0) + 1;
426
+ acc.total += 1;
427
+ return acc;
428
+ },
429
+ { total: 0 },
430
+ );
431
+
432
+ return formatResponse({
433
+ as_of: asOf(),
434
+ summary,
435
+ plans: plans.map((p) => ({
436
+ id: p.id,
437
+ title: p.title,
438
+ status: p.status,
439
+ visibility: p.visibility,
440
+ owner_id: p.owner_id || p.ownerId,
441
+ updated_at: p.updated_at || p.updatedAt,
442
+ // Surface progress + tether info when the API decorates the rows;
443
+ // listPlans on the v2 API now bulk-loads stats + goal_tethers.
444
+ progress: p.progress ?? p.stats?.percentage,
445
+ goal_tethers: p.goal_tethers,
446
+ })),
447
+ });
448
+ } catch (err) {
449
+ return errorResponse('upstream_unavailable', `list_plans failed: ${err.response?.data?.error || err.message}`);
450
+ }
451
+ }
452
+
351
453
  // ─────────────────────────────────────────────────────────────────────────
352
454
  // search — universal text search.
353
455
  // ─────────────────────────────────────────────────────────────────────────
@@ -376,13 +478,32 @@ const searchDefinition = {
376
478
 
377
479
  async function searchHandler(args, apiClient) {
378
480
  const { query, scope = 'global', scope_id, filters = {} } = args;
481
+ const limit = filters.limit || 20;
379
482
  try {
380
483
  let result;
381
- const limit = filters.limit || 20;
382
- if (scope === 'global') result = await apiClient.search.global(query, { limit, ...filters });
383
- else if (scope === 'plans') result = await apiClient.search.plans(query, limit);
384
- else if (scope === 'plan') result = await apiClient.search.inPlan(scope_id, query, limit);
385
- else if (scope === 'node') result = await apiClient.search.inNode(scope_id, query, limit);
484
+ // Map MCP scopes onto the actual api-client surface. The handler
485
+ // previously called apiClient.search.{global,plans,inPlan,inNode}
486
+ // none of those methods exist, so every invocation 500'd. The
487
+ // real api-client exposes globalSearch + searchPlan only; for
488
+ // 'plans' and 'node' we filter the global result client-side
489
+ // instead of hitting a (non-existent) per-bucket endpoint.
490
+ if (scope === 'plan') {
491
+ if (!scope_id) return errorResponse('invalid_arg', 'search scope=plan requires scope_id');
492
+ result = await apiClient.search.searchPlan(scope_id, query);
493
+ } else {
494
+ const global = await apiClient.search.globalSearch(query);
495
+ const all = Array.isArray(global?.results) ? global.results : [];
496
+ const matchScope = (r) => {
497
+ if (scope === 'global') return true;
498
+ if (scope === 'plans') return r.type === 'plan';
499
+ if (scope === 'node') return r.type === 'node' && (!scope_id || r.plan_id === scope_id);
500
+ return true;
501
+ };
502
+ const matchType = (r) => !filters.type || r.type === filters.type;
503
+ const matchStatus = (r) => !filters.status || r.status === filters.status;
504
+ const filtered = all.filter((r) => matchScope(r) && matchType(r) && matchStatus(r)).slice(0, limit);
505
+ result = { results: filtered, count: filtered.length, query, scope };
506
+ }
386
507
  return formatResponse({ as_of: asOf(), ...(result || {}) });
387
508
  } catch (err) {
388
509
  return errorResponse('upstream_unavailable', `Search failed: ${err.response?.data?.error || err.message}`);
@@ -437,6 +558,7 @@ module.exports = {
437
558
  taskContextDefinition,
438
559
  goalStateDefinition,
439
560
  recallKnowledgeDefinition,
561
+ listPlansDefinition,
440
562
  searchDefinition,
441
563
  planAnalysisDefinition,
442
564
  ],
@@ -445,6 +567,7 @@ module.exports = {
445
567
  task_context: taskContextHandler,
446
568
  goal_state: goalStateHandler,
447
569
  recall_knowledge: recallKnowledgeHandler,
570
+ list_plans: listPlansHandler,
448
571
  search: searchHandler,
449
572
  plan_analysis: planAnalysisHandler,
450
573
  },
@@ -281,15 +281,37 @@ const updateTaskDefinition = {
281
281
  type: 'string',
282
282
  description: 'Optional: also write a knowledge episode (recommended on completion)',
283
283
  },
284
+ session_id: {
285
+ type: 'string',
286
+ description: 'Optional work-session id returned by claim_next_task. Uses the agent-loop completion/block endpoint when status is completed or blocked.',
287
+ },
288
+ decision: {
289
+ type: 'object',
290
+ description: 'Optional decision to queue when blocking a session through the agent-loop endpoint.',
291
+ },
284
292
  },
285
293
  required: ['task_id'],
286
294
  },
287
295
  };
288
296
 
289
297
  async function updateTaskHandler(args, apiClient) {
290
- const { task_id, status, log_message, add_learning, release_claim } = args;
298
+ const { task_id, status, log_message, add_learning, release_claim, session_id, decision } = args;
291
299
  let planId = args.plan_id;
292
300
 
301
+ if (session_id && (status === 'completed' || status === 'blocked')) {
302
+ try {
303
+ const path = status === 'blocked' ? 'block' : 'complete';
304
+ const response = await apiClient.axiosInstance.post(`/agent/work-sessions/${session_id}/${path}`, {
305
+ summary: log_message,
306
+ learning: add_learning ? { content: add_learning } : undefined,
307
+ decision,
308
+ });
309
+ return formatResponse(response.data);
310
+ } catch {
311
+ // Fall back to legacy fan-out for older APIs or if the session was not found.
312
+ }
313
+ }
314
+
293
315
  // Resolve plan_id from task if not provided.
294
316
  if (!planId) {
295
317
  try {
@@ -405,6 +427,21 @@ async function claimNextTaskHandler(args, apiClient) {
405
427
  const { scope = {}, ttl_minutes = 30, fresh = false, context_depth = 2, dry_run = false } = args;
406
428
  const { plan_id, goal_id } = scope;
407
429
 
430
+ try {
431
+ const response = await apiClient.axiosInstance.post('/agent/work-sessions', {
432
+ plan_id,
433
+ goal_id,
434
+ ttl_minutes,
435
+ fresh,
436
+ dry_run,
437
+ depth: context_depth,
438
+ agent_id: 'mcp-agent',
439
+ });
440
+ return formatResponse(response.data);
441
+ } catch {
442
+ // Fall back to the pre-facade fan-out for self-hosted older APIs.
443
+ }
444
+
408
445
  let chosen = null;
409
446
  let source = null;
410
447
 
@@ -711,6 +748,37 @@ async function createSubtree(apiClient, planId, parentId, children, results) {
711
748
  async function formIntentionHandler(args, apiClient) {
712
749
  const { goal_id, title, description, rationale, status = 'active', visibility = 'private', tree = [] } = args;
713
750
 
751
+ if (apiClient.agentLoop?.createIntention) {
752
+ const treeError = validateTreeShape(tree);
753
+ if (treeError) {
754
+ return errorResponse('tree_shape_invalid', treeError);
755
+ }
756
+ try {
757
+ const result = await apiClient.agentLoop.createIntention({
758
+ goal_id,
759
+ title,
760
+ description,
761
+ rationale,
762
+ status,
763
+ visibility,
764
+ tree,
765
+ });
766
+ return formatResponse({
767
+ ...result,
768
+ plan_id: result.plan?.id || result.plan_id,
769
+ goal_id,
770
+ status: result.plan?.status || status,
771
+ is_draft: (result.plan?.status || status) === 'draft',
772
+ nodes_created: Array.isArray(result.tree) ? result.tree.length : undefined,
773
+ next_step: (result.plan?.status || status) === 'draft'
774
+ ? "Plan created as draft. Will surface in dashboard pending for human review. Auto-promotes to active when first task moves to in_progress."
775
+ : "Plan active. Claim a task with claim_next_task({plan_id}) to begin work.",
776
+ });
777
+ } catch {
778
+ // Fall through to the legacy multi-call path for older/self-hosted APIs.
779
+ }
780
+ }
781
+
714
782
  // Validate goal exists.
715
783
  let goal;
716
784
  try {
@@ -26,7 +26,7 @@ async function getStartedHandler(args) {
26
26
  "Beliefs (state queries), Desires (goals), and Intentions (committed actions). " +
27
27
  "Each tool answers one whole agentic question and returns an `as_of` timestamp.",
28
28
  tools_by_namespace: {
29
- beliefs: ['briefing', 'task_context', 'goal_state', 'recall_knowledge', 'search', 'plan_analysis'],
29
+ beliefs: ['briefing', 'list_plans', 'task_context', 'goal_state', 'recall_knowledge', 'search', 'plan_analysis'],
30
30
  desires: ['list_goals', 'update_goal'],
31
31
  intentions: ['claim_next_task', 'update_task', 'release_task', 'queue_decision', 'resolve_decision', 'add_learning'],
32
32
  },