agent-planner-mcp 1.5.0 → 1.5.3
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/package.json +1 -1
- package/src/oauth/consent.js +99 -0
- package/src/oauth/provider.js +99 -0
- package/src/oauth/store.js +111 -0
- package/src/server-http.js +97 -14
- package/src/tools/bdi/beliefs.js +19 -3
- package/src/tools/bdi/utility.js +12 -2
package/package.json
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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 & 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 };
|
package/src/server-http.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
package/src/tools/bdi/beliefs.js
CHANGED
|
@@ -463,8 +463,10 @@ async function listPlansHandler(args, apiClient) {
|
|
|
463
463
|
const q = filter.query.toLowerCase();
|
|
464
464
|
plans = plans.filter((p) => (p.title || '').toLowerCase().includes(q));
|
|
465
465
|
}
|
|
466
|
-
plans = plans.slice(0, filter.limit || 50);
|
|
467
466
|
|
|
467
|
+
// Summarize the FULL filtered set BEFORE paginating — otherwise `total` and
|
|
468
|
+
// the status counts only reflect the truncated page, making an agent think
|
|
469
|
+
// it has seen every plan when it hasn't.
|
|
468
470
|
const summary = plans.reduce(
|
|
469
471
|
(acc, p) => {
|
|
470
472
|
acc[p.status] = (acc[p.status] || 0) + 1;
|
|
@@ -474,10 +476,15 @@ async function listPlansHandler(args, apiClient) {
|
|
|
474
476
|
{ total: 0 },
|
|
475
477
|
);
|
|
476
478
|
|
|
479
|
+
const limit = filter.limit || 50;
|
|
480
|
+
const page = plans.slice(0, limit);
|
|
481
|
+
summary.returned = page.length;
|
|
482
|
+
summary.truncated = plans.length > page.length; // true → raise `filter.limit` to see the rest
|
|
483
|
+
|
|
477
484
|
return formatResponse({
|
|
478
485
|
as_of: asOf(),
|
|
479
486
|
summary,
|
|
480
|
-
plans:
|
|
487
|
+
plans: page.map((p) => ({
|
|
481
488
|
id: p.id,
|
|
482
489
|
title: p.title,
|
|
483
490
|
status: p.status,
|
|
@@ -537,7 +544,16 @@ async function searchHandler(args, apiClient) {
|
|
|
537
544
|
result = await apiClient.search.searchPlan(scope_id, query);
|
|
538
545
|
} else {
|
|
539
546
|
const global = await apiClient.search.globalSearch(query);
|
|
540
|
-
|
|
547
|
+
// The backend returns `results` as a GROUPED object
|
|
548
|
+
// ({ plans, nodes, comments, logs }), not a flat array — so the old
|
|
549
|
+
// `Array.isArray(results) ? … : []` always fell to [] and global search
|
|
550
|
+
// returned nothing. Flatten both shapes.
|
|
551
|
+
const r = global?.results;
|
|
552
|
+
const all = Array.isArray(r)
|
|
553
|
+
? r
|
|
554
|
+
: r && typeof r === 'object'
|
|
555
|
+
? [...(r.plans || []), ...(r.nodes || []), ...(r.comments || []), ...(r.logs || [])]
|
|
556
|
+
: [];
|
|
541
557
|
const matchScope = (r) => {
|
|
542
558
|
if (scope === 'global') return true;
|
|
543
559
|
if (scope === 'plans') return r.type === 'plan';
|
package/src/tools/bdi/utility.js
CHANGED
|
@@ -29,11 +29,20 @@ async function getStartedHandler(args) {
|
|
|
29
29
|
"Each tool answers one whole agentic question and returns an `as_of` timestamp.",
|
|
30
30
|
tools_by_namespace: {
|
|
31
31
|
beliefs: ['briefing', 'list_plans', 'task_context', 'goal_state', 'recall_knowledge', 'search', 'plan_analysis'],
|
|
32
|
-
desires: ['list_goals', 'update_goal'],
|
|
33
|
-
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'],
|
|
34
34
|
workspaces: ['list_workspaces', 'create_workspace', 'list_blueprints', 'fork_blueprint', 'save_as_blueprint'],
|
|
35
35
|
},
|
|
36
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
|
+
},
|
|
37
46
|
{
|
|
38
47
|
name: 'Mission control loop (Cowork autopilot or scheduled task)',
|
|
39
48
|
steps: [
|
|
@@ -62,6 +71,7 @@ async function getStartedHandler(args) {
|
|
|
62
71
|
},
|
|
63
72
|
],
|
|
64
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.',
|
|
65
75
|
'Tools are intent-shaped, not CRUD-shaped',
|
|
66
76
|
'Reads are bundled to minimize round trips',
|
|
67
77
|
'Writes are atomic where possible (update_task does status+log+release)',
|