agent-planner-mcp 1.4.1 → 1.5.1

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/AGENT_GUIDE.md CHANGED
@@ -42,13 +42,14 @@ get_started()
42
42
  ### Intentions — creation (v1.0)
43
43
  | Tool | When |
44
44
  |---|---|
45
- | `form_intention` | Create plan + initial tree under a goal, atomically |
45
+ | `form_intention` | Create plan + initial tree under a goal, atomically. Declare order inline with `ref`/`depends_on` (→ `blocks` edges); warns `created_without_dependencies` |
46
46
  | `extend_intention` | Add children under existing parent (lightweight, no queue) |
47
47
  | `propose_research_chain` | RPI triple with 2 blocking edges in one call |
48
48
 
49
49
  ### Intentions — structural mutation (v1.0)
50
50
  | Tool | When |
51
51
  |---|---|
52
+ | `list_plans` | List plans (filter by status / visibility / workspace) |
52
53
  | `update_plan` | Edit plan title/description/status/visibility/metadata |
53
54
  | `update_node` | Edit any node property except status |
54
55
  | `move_node` | Reparent within plan; cycle-safe |
@@ -148,9 +149,9 @@ Never use `add_learning(entry_type='decision')` to fake a decision queue. `queue
148
149
  ## Atomic patterns to remember
149
150
 
150
151
  - `update_task` does status + log + claim release + learning in one call. Don't decompose.
151
- - `claim_next_task` does suggest + claim + context. Don't decompose.
152
+ - `claim_next_task` does suggest + claim + context. Don't decompose. Fails closed — an empty result is structured: `reason: no_work_in_scope` (nothing left) vs `blocked_on_dep` (work remains but all of it is dependency-blocked). Don't treat empty as "done" without checking `reason`.
152
153
  - `briefing` does goals + decisions + tasks + activity + recommendation. Don't decompose.
153
- - `form_intention` creates plan + tree atomically. Don't trickle node-by-node.
154
+ - `form_intention` creates plan + tree atomically — and declares execution order inline via `ref`/`depends_on` (don't ship a bare hierarchy with no edges). Don't trickle node-by-node.
154
155
  - `share_plan` does visibility + add + remove in one call. Don't fan out.
155
156
 
156
157
  ## Output discipline
package/SKILL.md CHANGED
@@ -54,11 +54,12 @@ AgentPlanner exposes a **BDI-aligned** surface — Beliefs (state queries), Desi
54
54
  - `add_learning` — record a knowledge episode for future recall
55
55
 
56
56
  **Creation (v1.0):**
57
- - `form_intention` — create a plan + initial phase/task tree under a goal, atomically
57
+ - `form_intention` — create a plan + initial phase/task tree under a goal, atomically. **Declare execution order inline:** give nodes a `ref` and list prerequisites in `depends_on` (refs or titles) to create `blocks` edges in the same call. Returns a `structure` summary and warns `created_without_dependencies` when a multi-task plan has no edges — don't ship a bare hierarchy with no executable ordering. Every plan it creates is provenance-stamped (`created_by: agent-planner-mcp@<version>`) for version-drift diagnosis.
58
58
  - `extend_intention` — add children under an existing phase or task (lightweight, no decision-queue gate)
59
59
  - `propose_research_chain` — Research → Plan → Implement triple with two blocking edges, in one call
60
60
 
61
61
  **Structural mutation (v1.0):**
62
+ - `list_plans` — list plans (filter by status / visibility / workspace)
62
63
  - `update_plan` — edit any plan property (title, description, status, visibility, metadata)
63
64
  - `update_node` — edit any node property except status (status routes through `update_task`)
64
65
  - `move_node` — reparent within the same plan; cycle-safe
@@ -85,7 +86,7 @@ A Workspace is a folder under an Organization that owns goals + plans — a grou
85
86
 
86
87
  ### Utility
87
88
 
88
- - `get_started` — dynamic reference; call this if you're new to AgentPlanner
89
+ - `get_started` — dynamic reference; call this if you're new to AgentPlanner. Reports `mcp_version` so you can self-report your build (diagnose version drift across OpenClaw / Claude Code / local checkouts).
89
90
 
90
91
  ## Canonical workflows
91
92
 
@@ -138,6 +139,8 @@ claim_next_task({ scope: { plan_id }, dry_run: true })
138
139
  claim_next_task({ scope: { plan_id } }) // dry_run defaults to false
139
140
  ```
140
141
 
142
+ **Fails closed.** When nothing is claimable, `claim_next_task` never hands back a dependency-blind task — it returns a structured no-work result whose `reason` distinguishes `no_work_in_scope` (nothing left to do) from `blocked_on_dep` (work remains, but every remaining task is blocked on an incomplete dependency). Check `reason` before treating an empty result as "done."
143
+
141
144
  ### Proposing subtasks for human approval (v0.9.1+)
142
145
 
143
146
  For high-touch proposals (entire new directions, structural changes the human should review before they materialize), use `queue_decision` with `proposed_subtasks` — tasks only get created on `resolve_decision({action: 'approve'})`.
@@ -176,11 +179,17 @@ form_intention({
176
179
  title: 'Ship new auth flow',
177
180
  rationale: 'User-requested plan to migrate auth to passkeys',
178
181
  tree: [
179
- { node_type: 'phase', title: 'Discovery', children: [...] },
180
- { node_type: 'phase', title: 'Implementation', children: [...] },
182
+ { node_type: 'phase', title: 'Discovery', children: [
183
+ { ref: 'research', title: 'Research passkey libraries', task_mode: 'research' },
184
+ ]},
185
+ { node_type: 'phase', title: 'Implementation', children: [
186
+ { title: 'Implement passkey flow', task_mode: 'implement', depends_on: ['research'] },
187
+ ]},
181
188
  ]
182
189
  })
183
- // Plan lands as active. No approval needed user already approved by asking.
190
+ // Active, with a `blocks` edge (research implement) created inline from depends_on.
191
+ // Response carries a `structure` summary; a multi-task plan with zero edges would
192
+ // return created_without_dependencies + a warning instead.
184
193
  ```
185
194
 
186
195
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-planner-mcp",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
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": {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Consent + login surface for the OAuth authorization step.
3
+ *
4
+ * provider.authorize() renders this page (GET /authorize handled by the SDK).
5
+ * The form POSTs to /oauth/consent, which authenticates against the existing
6
+ * AgentPlanner /auth/login endpoint and, on success, mints a one-time
7
+ * authorization code bound to the user's AP credential, then redirects back to
8
+ * the client's redirect_uri with code + state.
9
+ */
10
+ const axios = require('axios');
11
+
12
+ const esc = (s = '') => String(s)
13
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
15
+
16
+ function renderConsentPage(params, { clientName = 'an application', error = null } = {}) {
17
+ const hidden = (name) => `<input type="hidden" name="${name}" value="${esc(params[name])}">`;
18
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <title>Connect AgentPlanner</title>
21
+ <style>
22
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0f1115;color:#e7e9ee;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
23
+ .card{background:#171a21;border:1px solid #262b36;border-radius:14px;padding:32px;width:360px;box-shadow:0 10px 40px rgba(0,0,0,.4)}
24
+ h1{font-size:18px;margin:0 0 4px}p{color:#9aa3b2;font-size:13px;margin:0 0 20px;line-height:1.5}
25
+ label{display:block;font-size:12px;color:#9aa3b2;margin:14px 0 6px}
26
+ input[type=email],input[type=password]{width:100%;box-sizing:border-box;padding:10px 12px;background:#0f1115;border:1px solid #2b3140;border-radius:8px;color:#e7e9ee;font-size:14px}
27
+ button{margin-top:22px;width:100%;padding:11px;background:#e0a96d;color:#1a1205;border:0;border-radius:8px;font-weight:600;font-size:14px;cursor:pointer}
28
+ .err{background:#3a1d1d;border:1px solid #6b2b2b;color:#f3b6b6;padding:9px 12px;border-radius:8px;font-size:12px;margin-bottom:14px}
29
+ .grant{color:#cfd5e1;font-size:12px;margin-top:16px}.grant b{color:#e7e9ee}
30
+ </style></head><body>
31
+ <div class="card">
32
+ <h1>Connect AgentPlanner</h1>
33
+ <p><b>${esc(clientName)}</b> wants to access your AgentPlanner plans, goals, and knowledge on your behalf.</p>
34
+ ${error ? `<div class="err">${esc(error)}</div>` : ''}
35
+ <form method="POST" action="/oauth/consent">
36
+ ${hidden('client_id')}${hidden('redirect_uri')}${hidden('code_challenge')}${hidden('code_challenge_method')}${hidden('state')}${hidden('scope')}${hidden('resource')}
37
+ <label for="email">Email</label>
38
+ <input id="email" name="email" type="email" autocomplete="username" required>
39
+ <label for="password">Password</label>
40
+ <input id="password" name="password" type="password" autocomplete="current-password" required>
41
+ <button type="submit">Sign in &amp; authorize</button>
42
+ </form>
43
+ <div class="grant">Signing in authorizes this connection only. You can disconnect <b>${esc(clientName)}</b> anytime from its connector settings, or from AgentPlanner → Settings → Connections.</div>
44
+ </div></body></html>`;
45
+ }
46
+
47
+ // Builds the POST /oauth/consent handler. `apiUrl` is the AgentPlanner REST base.
48
+ function makeConsentHandler({ store, apiUrl }) {
49
+ return async (req, res) => {
50
+ const b = req.body || {};
51
+ const params = {
52
+ client_id: b.client_id,
53
+ redirect_uri: b.redirect_uri,
54
+ code_challenge: b.code_challenge,
55
+ code_challenge_method: b.code_challenge_method,
56
+ state: b.state,
57
+ scope: b.scope,
58
+ resource: b.resource,
59
+ };
60
+
61
+ const client = await store.getClient(b.client_id);
62
+ if (!client) {
63
+ return res.status(400).send('Unknown client.');
64
+ }
65
+ // Defense-in-depth: redirect_uri must be one the client registered.
66
+ if (!Array.isArray(client.redirect_uris) || !client.redirect_uris.includes(b.redirect_uri)) {
67
+ return res.status(400).send('Invalid redirect_uri.');
68
+ }
69
+
70
+ let session;
71
+ try {
72
+ const resp = await axios.post(`${apiUrl}/auth/login`, { email: b.email, password: b.password }, { timeout: 10000 });
73
+ session = resp.data?.session;
74
+ var userId = resp.data?.user?.id;
75
+ } catch (err) {
76
+ const msg = err.response?.status === 401 ? 'Invalid email or password.' : 'Sign-in failed. Please try again.';
77
+ return res.status(200).send(renderConsentPage(params, { clientName: client.client_name, error: msg }));
78
+ }
79
+
80
+ if (!session?.access_token) {
81
+ return res.status(200).send(renderConsentPage(params, { clientName: client.client_name, error: 'Sign-in failed. Please try again.' }));
82
+ }
83
+
84
+ const code = await store.createCode({
85
+ clientId: b.client_id,
86
+ codeChallenge: b.code_challenge,
87
+ redirectUri: b.redirect_uri,
88
+ scopes: (b.scope || '').split(' ').filter(Boolean),
89
+ userId,
90
+ });
91
+
92
+ const url = new URL(b.redirect_uri);
93
+ url.searchParams.set('code', code);
94
+ if (b.state) url.searchParams.set('state', b.state);
95
+ return res.redirect(302, url.toString());
96
+ };
97
+ }
98
+
99
+ module.exports = { renderConsentPage, makeConsentHandler, esc };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * OAuthServerProvider for the hosted MCP authorization server.
3
+ *
4
+ * Plugs into the MCP SDK's mcpAuthRouter (discovery metadata, DCR, /authorize,
5
+ * /token, /revoke, PKCE validation). Persistence is delegated to the backend
6
+ * via BackendOAuthStore.
7
+ *
8
+ * Token model: the OAuth access_token is a short-lived (1h) AgentPlanner JWT
9
+ * minted from the consenting user (validated statelessly on /mcp). The refresh
10
+ * token is opaque, revocable, and bound to the client — backed by the backend's
11
+ * oauth_refresh_tokens table. Revoking it kills the connection within the
12
+ * access-token TTL. No AP credential is stored at rest.
13
+ */
14
+ const { renderConsentPage } = require('./consent');
15
+
16
+ class ApOAuthProvider {
17
+ constructor({ store }) {
18
+ this._store = store;
19
+
20
+ this.clientsStore = {
21
+ getClient: (clientId) => this._store.getClient(clientId),
22
+ registerClient: (client) => this._store.registerClient(client),
23
+ };
24
+ }
25
+
26
+ // Render the consent/login page. mcpAuthRouter's /authorize handler has
27
+ // already validated redirect_uri against the registered client.
28
+ async authorize(client, params, res) {
29
+ res.status(200).set('Content-Type', 'text/html').send(renderConsentPage({
30
+ client_id: client.client_id,
31
+ redirect_uri: params.redirectUri,
32
+ code_challenge: params.codeChallenge,
33
+ code_challenge_method: 'S256',
34
+ state: params.state,
35
+ scope: (params.scopes || []).join(' '),
36
+ resource: params.resource ? params.resource.toString() : '',
37
+ }, { clientName: client.client_name }));
38
+ }
39
+
40
+ async challengeForAuthorizationCode(client, authorizationCode) {
41
+ const rec = await this._store.getCode(authorizationCode);
42
+ if (!rec || rec.clientId !== client.client_id) {
43
+ throw new Error('invalid_grant: unknown authorization code');
44
+ }
45
+ return rec.codeChallenge;
46
+ }
47
+
48
+ // Backend consume validates client/redirect/PKCE-bound code, mints + returns
49
+ // the token set (short-lived access JWT + opaque, revocable refresh token).
50
+ async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri) {
51
+ const tokens = await this._store.consumeCode(authorizationCode, {
52
+ clientId: client.client_id,
53
+ redirectUri,
54
+ });
55
+ if (!tokens || !tokens.access_token) {
56
+ throw new Error('invalid_grant: authorization code is invalid or expired');
57
+ }
58
+ return tokens;
59
+ }
60
+
61
+ // Rotate the opaque refresh token (bound to client_id) for a fresh token set.
62
+ async exchangeRefreshToken(client, refreshToken, _scopes) {
63
+ const tokens = await this._store.refresh(refreshToken, client.client_id);
64
+ if (!tokens || !tokens.access_token) {
65
+ throw new Error('invalid_grant: refresh token is invalid or expired');
66
+ }
67
+ return tokens;
68
+ }
69
+
70
+ // RFC 7009 revocation — revoke the (opaque) refresh token, killing the
71
+ // connection. Enables /oauth/revoke so connectors can disconnect.
72
+ async revokeToken(_client, request) {
73
+ if (request?.token) await this._store.revoke(request.token);
74
+ }
75
+
76
+ // Access tokens are AP JWTs; the MCP doesn't hold JWT_SECRET, so this is a
77
+ // structural/expiry decode only — the AP API performs real validation when it
78
+ // receives the JWT. /mcp itself uses the server-http auth middleware, not this.
79
+ async verifyAccessToken(token) {
80
+ const parts = token.split('.');
81
+ if (parts.length !== 3) throw new Error('invalid_token');
82
+ let payload;
83
+ try {
84
+ payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
85
+ } catch {
86
+ throw new Error('invalid_token');
87
+ }
88
+ if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('invalid_token: expired');
89
+ return {
90
+ token,
91
+ clientId: payload.client_id || 'agentplanner',
92
+ scopes: ['agentplanner'],
93
+ expiresAt: payload.exp,
94
+ extra: { apToken: token, userId: payload.sub || payload.userId },
95
+ };
96
+ }
97
+ }
98
+
99
+ module.exports = { ApOAuthProvider };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * OAuth state store — thin HTTP client over the AgentPlanner backend's
3
+ * secret-guarded /internal/oauth endpoints. The MCP server has no database of
4
+ * its own; DCR clients and one-time PKCE codes live in the backend's Postgres.
5
+ *
6
+ * There is no token storage: the OAuth access_token is the user's AP JWT, so a
7
+ * restart never drops authenticated connections.
8
+ */
9
+ const axios = require('axios');
10
+
11
+ // Map a backend (camelCase) client row → the SDK's OAuthClientInformationFull
12
+ // (snake_case) shape that mcpAuthRouter / the provider expect.
13
+ function toSdkClient(row) {
14
+ if (!row) return undefined;
15
+ return {
16
+ client_id: row.clientId,
17
+ ...(row.clientSecret ? { client_secret: row.clientSecret, client_secret_expires_at: 0 } : {}),
18
+ client_name: row.clientName || undefined,
19
+ redirect_uris: row.redirectUris || [],
20
+ grant_types: row.grantTypes || [],
21
+ response_types: row.responseTypes || [],
22
+ scope: row.scope || undefined,
23
+ token_endpoint_auth_method: row.tokenEndpointAuthMethod || 'client_secret_basic',
24
+ client_id_issued_at: row.clientIdIssuedAt ? Math.floor(new Date(row.clientIdIssuedAt).getTime() / 1000) : undefined,
25
+ };
26
+ }
27
+
28
+ class BackendOAuthStore {
29
+ constructor({ apiUrl, internalSecret }) {
30
+ this.base = `${(apiUrl || 'http://localhost:3000').replace(/\/$/, '')}/internal/oauth`;
31
+ this.http = axios.create({
32
+ timeout: 10000,
33
+ headers: { 'X-Internal-Token': internalSecret || '' },
34
+ });
35
+ }
36
+
37
+ async getClient(clientId) {
38
+ try {
39
+ const { data } = await this.http.get(`${this.base}/clients/${encodeURIComponent(clientId)}`);
40
+ return toSdkClient(data);
41
+ } catch (err) {
42
+ if (err.response?.status === 404) return undefined;
43
+ throw err;
44
+ }
45
+ }
46
+
47
+ // SDK passes the client minus client_id (snake_case). Backend mints id/secret.
48
+ async registerClient(client) {
49
+ const { data } = await this.http.post(`${this.base}/clients`, client);
50
+ return toSdkClient(data);
51
+ }
52
+
53
+ // Stores the code bound to the authenticated user (no AP credential at rest).
54
+ async createCode({ clientId, codeChallenge, redirectUri, scopes, userId }) {
55
+ const { data } = await this.http.post(`${this.base}/codes`, {
56
+ client_id: clientId,
57
+ code_challenge: codeChallenge,
58
+ redirect_uri: redirectUri,
59
+ scopes: scopes || [],
60
+ user_id: userId || null,
61
+ });
62
+ return data.code;
63
+ }
64
+
65
+ // Peek (no consume) for the PKCE challenge lookup.
66
+ async getCode(code) {
67
+ try {
68
+ const { data } = await this.http.get(`${this.base}/codes/${encodeURIComponent(code)}`);
69
+ return { clientId: data.client_id, codeChallenge: data.code_challenge, redirectUri: data.redirect_uri };
70
+ } catch (err) {
71
+ if (err.response?.status === 404) return null;
72
+ throw err;
73
+ }
74
+ }
75
+
76
+ // One-time consume → backend validates client/redirect, mints + returns the
77
+ // OAuth token set (access JWT + opaque refresh). Null if the code is invalid.
78
+ async consumeCode(code, { clientId, redirectUri } = {}) {
79
+ try {
80
+ const { data } = await this.http.post(`${this.base}/codes/${encodeURIComponent(code)}/consume`, {
81
+ client_id: clientId,
82
+ redirect_uri: redirectUri,
83
+ });
84
+ return data; // { access_token, token_type, expires_in, refresh_token, scope }
85
+ } catch (err) {
86
+ if (err.response?.status === 404 || err.response?.status === 400) return null;
87
+ throw err;
88
+ }
89
+ }
90
+
91
+ // Rotate a refresh token → new token set (bound to client_id).
92
+ async refresh(refreshToken, clientId) {
93
+ try {
94
+ const { data } = await this.http.post(`${this.base}/refresh`, {
95
+ refresh_token: refreshToken,
96
+ client_id: clientId,
97
+ });
98
+ return data;
99
+ } catch (err) {
100
+ if (err.response?.status === 400) return null;
101
+ throw err;
102
+ }
103
+ }
104
+
105
+ // Revoke a refresh token (RFC 7009) — kills the connection.
106
+ async revoke(token) {
107
+ await this.http.post(`${this.base}/revoke`, { token });
108
+ }
109
+ }
110
+
111
+ module.exports = { BackendOAuthStore, toSdkClient };
@@ -17,7 +17,15 @@ const { SessionManager } = require('./session-manager');
17
17
  const { setupTools } = require('./tools');
18
18
  const { createApiClient } = require('./api-client');
19
19
  const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
20
+ const { createOAuthMetadata, mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl } = require('@modelcontextprotocol/sdk/server/auth/router.js');
21
+ const { authorizationHandler } = require('@modelcontextprotocol/sdk/server/auth/handlers/authorize.js');
22
+ const { tokenHandler } = require('@modelcontextprotocol/sdk/server/auth/handlers/token.js');
23
+ const { clientRegistrationHandler } = require('@modelcontextprotocol/sdk/server/auth/handlers/register.js');
24
+ const { revocationHandler } = require('@modelcontextprotocol/sdk/server/auth/handlers/revoke.js');
20
25
  const { version } = require('../package.json');
26
+ const { BackendOAuthStore } = require('./oauth/store');
27
+ const { ApOAuthProvider } = require('./oauth/provider');
28
+ const { makeConsentHandler } = require('./oauth/consent');
21
29
  require('dotenv').config();
22
30
 
23
31
  // MCP Protocol Version
@@ -37,6 +45,15 @@ class MCPHTTPServer {
37
45
  // Store for pending SSE streams per session
38
46
  this.sseStreams = new Map(); // sessionId -> { res, req }
39
47
 
48
+ // OAuth authorization server (for claude.ai / Claude Design connectors,
49
+ // which require the MCP OAuth handshake — static ApiKey is rejected there).
50
+ // The issuer/public origin is where claude.ai reaches the AS endpoints.
51
+ this.publicBaseUrl = (options.publicBaseUrl || process.env.OAUTH_ISSUER_URL || process.env.PUBLIC_URL || 'https://agentplanner.io').replace(/\/$/, '');
52
+ this.apiUrl = options.apiUrl || process.env.API_URL || 'http://localhost:3000';
53
+ this.oauthStore = new BackendOAuthStore({ apiUrl: this.apiUrl, internalSecret: process.env.MCP_INTERNAL_SECRET });
54
+ this.oauthProvider = new ApOAuthProvider({ store: this.oauthStore, apiUrl: this.apiUrl });
55
+ this.resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(new URL(`${this.publicBaseUrl}/mcp`));
56
+
40
57
  // Create Express app
41
58
  this.app = express();
42
59
 
@@ -51,15 +68,70 @@ class MCPHTTPServer {
51
68
  * Setup Express middleware
52
69
  */
53
70
  setupMiddleware() {
54
- // Parse JSON bodies
71
+ // Behind nginx: trust the proxy so express-rate-limit (on the OAuth
72
+ // endpoints) reads the real client IP from X-Forwarded-For instead of
73
+ // throwing ERR_ERL_UNEXPECTED_X_FORWARDED_FOR.
74
+ this.app.set('trust proxy', 1);
75
+
76
+ // Parse JSON + urlencoded bodies (OAuth /token uses form encoding; the
77
+ // consent form posts urlencoded; /register + MCP use JSON).
55
78
  this.app.use(express.json());
79
+ this.app.use(express.urlencoded({ extended: false }));
56
80
 
57
- // Logging middleware
81
+ // Logging middleware — log the request and, on finish, the status (+ the
82
+ // redirect target for 3xx, which is critical for debugging the OAuth flow).
58
83
  this.app.use((req, res, next) => {
59
84
  console.error(`${req.method} ${req.path} - ${req.get('MCP-Protocol-Version') || 'no version'}`);
85
+ res.on('finish', () => {
86
+ const loc = res.getHeader('location');
87
+ console.error(` ↳ ${req.method} ${req.path} → ${res.statusCode}${loc ? ` Location=${loc}` : ''}`);
88
+ });
60
89
  next();
61
90
  });
62
91
 
92
+ // ── OAuth authorization server ───────────────────────────────────────────
93
+ // Mounted BEFORE the MCP auth/version/origin middleware so the browser- and
94
+ // connector-facing OAuth endpoints bypass the MCP-protocol checks. Unmatched
95
+ // paths (e.g. /mcp) fall through to the middleware below.
96
+ //
97
+ // The endpoints live under /oauth/* — NOT the SDK's default root paths —
98
+ // because /register would otherwise collide with the web UI's signup route.
99
+ // The SDK's mcpAuthRouter hard-codes root-relative endpoint paths, so we
100
+ // compose it by hand: serve discovery metadata advertising the /oauth/* URLs,
101
+ // and mount the handlers under /oauth.
102
+ const issuerUrl = new URL(this.publicBaseUrl);
103
+ const oauthBase = `${this.publicBaseUrl}/oauth`;
104
+ const baseMetadata = createOAuthMetadata({ provider: this.oauthProvider, issuerUrl, scopesSupported: ['agentplanner'] });
105
+ const oauthMetadata = {
106
+ ...baseMetadata,
107
+ authorization_endpoint: `${oauthBase}/authorize`,
108
+ token_endpoint: `${oauthBase}/token`,
109
+ registration_endpoint: baseMetadata.registration_endpoint ? `${oauthBase}/register` : undefined,
110
+ revocation_endpoint: baseMetadata.revocation_endpoint ? `${oauthBase}/revoke` : undefined,
111
+ };
112
+
113
+ // Discovery metadata at the RFC well-known paths (AS metadata at root,
114
+ // protected-resource at /.well-known/oauth-protected-resource/mcp).
115
+ this.app.use(mcpAuthMetadataRouter({
116
+ oauthMetadata,
117
+ resourceServerUrl: new URL(`${this.publicBaseUrl}/mcp`),
118
+ scopesSupported: ['agentplanner'],
119
+ resourceName: 'AgentPlanner',
120
+ }));
121
+
122
+ // AS endpoints under /oauth/* (matches the advertised metadata URLs).
123
+ const oauthRouter = express.Router();
124
+ oauthRouter.use('/authorize', authorizationHandler({ provider: this.oauthProvider }));
125
+ oauthRouter.use('/token', tokenHandler({ provider: this.oauthProvider }));
126
+ oauthRouter.post('/consent', makeConsentHandler({ store: this.oauthStore, apiUrl: this.apiUrl }));
127
+ oauthRouter.use('/register', clientRegistrationHandler({ clientsStore: this.oauthProvider.clientsStore }));
128
+ // Revocation only if the provider supports it. Access tokens are stateless
129
+ // AP JWTs (no denylist), so we don't — and the metadata omits the endpoint.
130
+ if (typeof this.oauthProvider.revokeToken === 'function') {
131
+ oauthRouter.use('/revoke', revocationHandler({ provider: this.oauthProvider }));
132
+ }
133
+ this.app.use('/oauth', oauthRouter);
134
+
63
135
  // Protocol version validation
64
136
  this.app.use((req, res, next) => {
65
137
  // Skip version check for health and discovery endpoints
@@ -79,28 +151,39 @@ class MCPHTTPServer {
79
151
  next();
80
152
  });
81
153
 
82
- // Authentication — require Authorization header on /mcp
154
+ // Authentication — require Authorization header on /mcp.
155
+ // Three credential types are accepted:
156
+ // 1. ApiKey <token> — AP API token (legacy, Desktop/Code/CLI)
157
+ // 2. Bearer <ap-jwt> — raw AP JWT (legacy)
158
+ // 3. Bearer <oauth-tok> — opaque token issued by our OAuth AS; resolved
159
+ // to the user's AP JWT (claude.ai / Claude Design)
160
+ // On a missing/invalid token we emit WWW-Authenticate with the protected-
161
+ // resource metadata URL so OAuth clients can discover the auth server.
83
162
  this.app.use((req, res, next) => {
84
- if (req.path === '/health' || req.path === '/.well-known/mcp.json') return next();
163
+ if (req.path === '/health' || req.path.startsWith('/.well-known/')) return next();
164
+
165
+ const unauthorized = (message) => {
166
+ res.set('WWW-Authenticate', `Bearer resource_metadata="${this.resourceMetadataUrl}"`);
167
+ return res.status(401).json({ jsonrpc: '2.0', error: { code: -32000, message } });
168
+ };
85
169
 
86
170
  const authHeader = req.get('Authorization');
87
171
  if (!authHeader) {
88
- return res.status(401).json({
89
- jsonrpc: '2.0',
90
- error: { code: -32000, message: 'Authorization header required. Use "Authorization: Bearer <token>" or "Authorization: ApiKey <token>".' }
91
- });
172
+ return unauthorized('Authorization required. Use OAuth (add AgentPlanner as a connector) or "Authorization: ApiKey <token>".');
92
173
  }
93
174
 
94
175
  const parts = authHeader.split(' ');
95
176
  if (parts.length !== 2 || !['Bearer', 'ApiKey'].includes(parts[0])) {
96
- return res.status(401).json({
97
- jsonrpc: '2.0',
98
- error: { code: -32000, message: 'Invalid Authorization format. Use "Bearer <token>" or "ApiKey <token>".' }
99
- });
177
+ return unauthorized('Invalid Authorization format. Use "Bearer <token>" or "ApiKey <token>".');
100
178
  }
101
179
 
102
- // Store the raw token for per-session API client creation
103
- req.userToken = parts[1];
180
+ const [, token] = parts;
181
+
182
+ // The token is an AP credential: an API key, a raw AP JWT, or an
183
+ // OAuth-issued access token (which IS an AP JWT — see oauth/provider.js).
184
+ // All three are passed straight to the per-session API client; the AP API
185
+ // validates JWTs/keys. No separate OAuth token store to consult.
186
+ req.userToken = token;
104
187
  next();
105
188
  });
106
189
 
@@ -14,6 +14,11 @@
14
14
  */
15
15
 
16
16
  const { asOf, formatResponse, errorResponse, isV1Unavailable } = require('./_shared');
17
+ const { version: PKG_VERSION } = require('../../../package.json');
18
+
19
+ // Provenance tag stamped onto every plan this server creates, so a plan stays
20
+ // debuggable later even if this MCP build is stale relative to the API.
21
+ const CLIENT_TAG = `agent-planner-mcp@${PKG_VERSION}`;
17
22
 
18
23
  // ─────────────────────────────────────────────────────────────────────────
19
24
  // queue_decision — real decision queue. Replaces add_learning workaround.
@@ -737,10 +742,14 @@ const formIntentionDefinition = {
737
742
  name: 'form_intention',
738
743
  description:
739
744
  "Create a plan that achieves a goal, including an initial phase/task " +
740
- "tree, in one call. Defaults to status='active' for human-directed " +
741
- "creation; pass status='draft' for autonomous loops so a human can " +
742
- "review before promotion. Drafts surface in the dashboard pending " +
743
- "queue and auto-promote to active when work begins on any node.",
745
+ "tree, in one call. Declare execution order inline: give nodes a `ref` and " +
746
+ "list prerequisite refs/titles in `depends_on` to create 'blocks' edges in " +
747
+ "the same call don't ship a bare hierarchy. The response returns a " +
748
+ "`structure` summary and warns (`created_without_dependencies`) when a " +
749
+ "multi-task plan has no edges. Defaults to status='active' for " +
750
+ "human-directed creation; pass status='draft' for autonomous loops so a " +
751
+ "human can review before promotion. Drafts surface in the dashboard " +
752
+ "pending queue and auto-promote to active when work begins on any node.",
744
753
  inputSchema: {
745
754
  type: 'object',
746
755
  properties: {
@@ -769,6 +778,12 @@ const formIntentionDefinition = {
769
778
  description: { type: 'string' },
770
779
  task_mode: { type: 'string', enum: VALID_TASK_MODES, default: 'free' },
771
780
  agent_instructions: { type: 'string' },
781
+ ref: { type: 'string', description: "Optional stable key so other nodes can reference this one in depends_on. Falls back to title if omitted." },
782
+ depends_on: {
783
+ type: 'array',
784
+ items: { type: 'string' },
785
+ description: "Refs (or titles) of nodes that must complete before this one. Creates a 'blocks' edge from each prerequisite to this node.",
786
+ },
772
787
  children: { type: 'array' },
773
788
  },
774
789
  required: ['title'],
@@ -779,7 +794,10 @@ const formIntentionDefinition = {
779
794
  },
780
795
  };
781
796
 
782
- async function createSubtree(apiClient, planId, parentId, children, results) {
797
+ // `ctx` (optional) collects ref/title id maps and depends_on intents so the
798
+ // caller can wire dependency edges after the whole tree exists. Omitted by
799
+ // callers that don't support inline deps (e.g. extend_intention).
800
+ async function createSubtree(apiClient, planId, parentId, children, results, ctx = null) {
783
801
  for (const child of children || []) {
784
802
  let createdNode;
785
803
  try {
@@ -801,8 +819,18 @@ async function createSubtree(apiClient, planId, parentId, children, results) {
801
819
  continue;
802
820
  }
803
821
 
822
+ if (ctx && createdNode?.id) {
823
+ if (child.ref) ctx.refMap.set(String(child.ref), createdNode.id);
824
+ const list = ctx.titleMap.get(child.title) || [];
825
+ list.push(createdNode.id);
826
+ ctx.titleMap.set(child.title, list);
827
+ if (Array.isArray(child.depends_on) && child.depends_on.length) {
828
+ ctx.edgeIntents.push({ dependsOn: child.depends_on.map(String), targetId: createdNode.id });
829
+ }
830
+ }
831
+
804
832
  if (child.children?.length && createdNode?.id) {
805
- await createSubtree(apiClient, planId, createdNode.id, child.children, results);
833
+ await createSubtree(apiClient, planId, createdNode.id, child.children, results, ctx);
806
834
  }
807
835
  }
808
836
  }
@@ -824,6 +852,7 @@ async function formIntentionHandler(args, apiClient) {
824
852
  status,
825
853
  visibility,
826
854
  tree,
855
+ client_version: CLIENT_TAG,
827
856
  });
828
857
  return formatResponse({
829
858
  ...result,
@@ -866,6 +895,7 @@ async function formIntentionHandler(args, apiClient) {
866
895
  title,
867
896
  description: composedDescription,
868
897
  status,
898
+ metadata: { created_by: CLIENT_TAG },
869
899
  });
870
900
  } catch (err) {
871
901
  return errorResponse('create_failed', `Failed to create plan: ${err.response?.data?.error || err.message}`);
@@ -889,9 +919,48 @@ async function formIntentionHandler(args, apiClient) {
889
919
 
890
920
  // 3. Create tree (top-level children parent to root via omitted parent_id).
891
921
  const nodeResults = [];
892
- await createSubtree(apiClient, plan.id, null, tree, nodeResults);
922
+ const ctx = { refMap: new Map(), titleMap: new Map(), edgeIntents: [] };
923
+ await createSubtree(apiClient, plan.id, null, tree, nodeResults, ctx);
924
+
925
+ // 4. Wire inline dependency edges. depends_on:[X] on N means X blocks N.
926
+ const resolveRef = (ref) => {
927
+ if (ctx.refMap.has(ref)) return ctx.refMap.get(ref);
928
+ const byTitle = ctx.titleMap.get(ref);
929
+ return byTitle && byTitle.length === 1 ? byTitle[0] : null;
930
+ };
931
+ const dependencyWarnings = [];
932
+ let dependencyEdges = 0;
933
+ for (const intent of ctx.edgeIntents) {
934
+ for (const ref of intent.dependsOn) {
935
+ const sourceId = resolveRef(ref);
936
+ if (!sourceId || sourceId === intent.targetId) {
937
+ dependencyWarnings.push(`Unresolved, ambiguous, or self depends_on reference "${ref}"`);
938
+ continue;
939
+ }
940
+ try {
941
+ await apiClient.axiosInstance.post('/dependencies', {
942
+ source_node_id: sourceId,
943
+ target_node_id: intent.targetId,
944
+ dependency_type: 'blocks',
945
+ });
946
+ dependencyEdges += 1;
947
+ } catch (err) {
948
+ dependencyWarnings.push(`Edge ${ref}→task rejected: ${err.response?.data?.error || err.message}`);
949
+ }
950
+ }
951
+ }
893
952
 
894
- return formatResponse({
953
+ const taskCount = nodeResults.filter((n) => n.id && (n.node_type === 'task' || n.node_type === 'milestone')).length;
954
+ const createdWithoutDependencies = taskCount >= 2 && dependencyEdges === 0;
955
+ const structure = {
956
+ task_count: taskCount,
957
+ dependency_edges: dependencyEdges,
958
+ created_without_dependencies: createdWithoutDependencies,
959
+ created_by: CLIENT_TAG,
960
+ };
961
+ if (dependencyWarnings.length) structure.dependency_warnings = dependencyWarnings;
962
+
963
+ const response = {
895
964
  as_of: asOf(),
896
965
  plan_id: plan.id,
897
966
  goal_id,
@@ -900,10 +969,18 @@ async function formIntentionHandler(args, apiClient) {
900
969
  nodes_created: nodeResults.filter((n) => n.id).length,
901
970
  node_failures: nodeResults.filter((n) => n.error),
902
971
  nodes: nodeResults,
972
+ structure,
903
973
  next_step: plan.status === 'draft'
904
974
  ? "Plan created as draft. Will surface in dashboard pending for human review. Auto-promotes to active when first task moves to in_progress."
905
975
  : "Plan active. Claim a task with claim_next_task({plan_id}) to begin work.",
906
- });
976
+ };
977
+ if (createdWithoutDependencies) {
978
+ response.warning =
979
+ `Plan has ${taskCount} tasks but no dependency edges — execution order is implicit only.`;
980
+ response.next_required_action =
981
+ 'Call link_intentions to add blocking edges, or confirm the tasks are order-independent.';
982
+ }
983
+ return formatResponse(response);
907
984
  }
908
985
 
909
986
  // ─────────────────────────────────────────────────────────────────────────
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  const { asOf, formatResponse } = require('./_shared');
6
+ const { version: MCP_VERSION } = require('../../../package.json');
6
7
 
7
8
  const getStartedDefinition = {
8
9
  name: 'get_started',
@@ -21,17 +22,27 @@ const getStartedDefinition = {
21
22
  async function getStartedHandler(args) {
22
23
  return formatResponse({
23
24
  as_of: asOf(),
25
+ mcp_version: MCP_VERSION,
24
26
  overview:
25
27
  "AgentPlanner exposes a BDI-aligned MCP surface. Tools are grouped by " +
26
28
  "Beliefs (state queries), Desires (goals), and Intentions (committed actions). " +
27
29
  "Each tool answers one whole agentic question and returns an `as_of` timestamp.",
28
30
  tools_by_namespace: {
29
31
  beliefs: ['briefing', 'list_plans', 'task_context', 'goal_state', 'recall_knowledge', 'search', 'plan_analysis'],
30
- desires: ['list_goals', 'update_goal'],
31
- intentions: ['claim_next_task', 'update_task', 'release_task', 'queue_decision', 'resolve_decision', 'add_learning'],
32
+ desires: ['list_goals', 'create_goal', 'update_goal', 'derive_subgoal'],
33
+ intentions: ['form_intention', 'extend_intention', 'link_intentions', 'propose_research_chain', 'claim_next_task', 'update_task', 'update_node', 'release_task', 'queue_decision', 'resolve_decision', 'add_learning'],
32
34
  workspaces: ['list_workspaces', 'create_workspace', 'list_blueprints', 'fork_blueprint', 'save_as_blueprint'],
33
35
  },
34
36
  recommended_workflows: [
37
+ {
38
+ name: 'Set up new work a human asked for',
39
+ steps: [
40
+ 'list_goals / recall_knowledge — check what already exists',
41
+ 'create_goal(...) — create the goal directly (status active). Agents create goals; there is no UI step or approval gate when a human asked.',
42
+ 'form_intention(goal_id, nodes with ref + depends_on) — create the plan + task tree atomically, with execution order declared inline',
43
+ 'Then execute it: claim_next_task → update_task',
44
+ ],
45
+ },
35
46
  {
36
47
  name: 'Mission control loop (Cowork autopilot or scheduled task)',
37
48
  steps: [
@@ -60,6 +71,7 @@ async function getStartedHandler(args) {
60
71
  },
61
72
  ],
62
73
  key_principles: [
74
+ 'Agents create goals AND plans, not just execute — when a human asks you to set something up, use create_goal / form_intention directly (no UI round-trip, no approval gate). The UI is for human oversight, not the only way to create work.',
63
75
  'Tools are intent-shaped, not CRUD-shaped',
64
76
  'Reads are bundled to minimize round trips',
65
77
  'Writes are atomic where possible (update_task does status+log+release)',