aiquila-mcp 0.3.29 → 0.3.30
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/dist/auth/provider.js +5 -2
- package/dist/transports/http.js +51 -3
- package/dist/transports/lazy-auth.js +63 -0
- package/package.json +1 -1
package/dist/auth/provider.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { SignJWT, jwtVerify } from 'jose';
|
|
4
|
-
import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
4
|
+
import { InvalidGrantError, InvalidTokenError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
5
5
|
import { ClientsStore, CodeStore, RefreshStore } from './store.js';
|
|
6
6
|
import { logger } from '../logger.js';
|
|
7
7
|
// --- JWT helpers (HMAC-SHA256 via jose) ---
|
|
@@ -196,7 +196,10 @@ export class NextcloudOAuthProvider {
|
|
|
196
196
|
const claims = await verifyJwt(token, this.getSecret(), issuer ? { issuer, audience: new URL('/mcp', issuer).toString() } : undefined);
|
|
197
197
|
if (!claims) {
|
|
198
198
|
logger.warn('[auth] Access token verification failed: invalid or expired token');
|
|
199
|
-
|
|
199
|
+
// InvalidTokenError (not a bare Error) so the SDK answers 401 with a
|
|
200
|
+
// WWW-Authenticate challenge. A bare Error becomes a 500, and clients
|
|
201
|
+
// treat that as a server fault rather than a cue to re-authenticate.
|
|
202
|
+
throw new InvalidTokenError('Invalid or expired access token');
|
|
200
203
|
}
|
|
201
204
|
return {
|
|
202
205
|
token,
|
package/dist/transports/http.js
CHANGED
|
@@ -9,10 +9,16 @@ import { createServer, SERVER_VERSION } from '../server.js';
|
|
|
9
9
|
import { NextcloudOAuthProvider } from '../auth/provider.js';
|
|
10
10
|
import { probeStateDir, StateDirNotWritableError, stateUnwritableMessage, markStateUnwritableWarned, } from '../auth/store.js';
|
|
11
11
|
import { loginHandler } from '../auth/login.js';
|
|
12
|
+
import { isPublicRequest } from './lazy-auth.js';
|
|
12
13
|
import { logger } from '../logger.js';
|
|
13
14
|
import { fetchStatus } from '../client/ocs.js';
|
|
14
15
|
const DEFAULT_PORT = 3339;
|
|
15
16
|
const MCP_PATH = '/mcp';
|
|
17
|
+
const SCOPES_SUPPORTED = ['mcp:tools', 'mcp:resources', 'mcp:prompts'];
|
|
18
|
+
// Scope advertised in the WWW-Authenticate challenge. Clients request exactly
|
|
19
|
+
// this on authorisation; omitting it makes them fall back to the full
|
|
20
|
+
// `scopes_supported` union, which produces an over-broad consent prompt.
|
|
21
|
+
const CHALLENGE_SCOPE = 'mcp:tools';
|
|
16
22
|
// TLS error codes that indicate a self-signed or untrusted certificate.
|
|
17
23
|
// Network errors (ECONNREFUSED, ETIMEDOUT) are not included — they mean the
|
|
18
24
|
// proxy is not yet reachable, which is transient and should not log a warning.
|
|
@@ -81,6 +87,10 @@ export async function startHttp() {
|
|
|
81
87
|
const port = parseInt(process.env.MCP_PORT || String(DEFAULT_PORT), 10);
|
|
82
88
|
const host = process.env.MCP_HOST || '0.0.0.0';
|
|
83
89
|
const authEnabled = process.env.MCP_AUTH_ENABLED === 'true';
|
|
90
|
+
// Lazy authentication is on by default: clients need to inspect a connector
|
|
91
|
+
// before signing in. Operators who treat /mcp as a fully closed endpoint can
|
|
92
|
+
// set MCP_LAZY_AUTH=false to require a bearer token on every JSON-RPC method.
|
|
93
|
+
const lazyAuthEnabled = process.env.MCP_LAZY_AUTH !== 'false';
|
|
84
94
|
if (!authEnabled) {
|
|
85
95
|
if (process.env.MCP_ALLOW_UNAUTHENTICATED !== 'true') {
|
|
86
96
|
throw new Error('HTTP transport requires MCP_AUTH_ENABLED=true. ' +
|
|
@@ -220,7 +230,7 @@ export async function startHttp() {
|
|
|
220
230
|
issuerUrl: new URL(issuerUrl),
|
|
221
231
|
resourceServerUrl: new URL(MCP_PATH, issuerUrl),
|
|
222
232
|
serviceDocumentationUrl: new URL('https://github.com/elgorro/aiquila'),
|
|
223
|
-
scopesSupported:
|
|
233
|
+
scopesSupported: SCOPES_SUPPORTED,
|
|
224
234
|
}));
|
|
225
235
|
// Fallback: serve protected resource metadata at the root well-known path too,
|
|
226
236
|
// so clients that use the server root URL (without /mcp) can discover the resource.
|
|
@@ -229,7 +239,7 @@ export async function startHttp() {
|
|
|
229
239
|
res.json({
|
|
230
240
|
resource: new URL(MCP_PATH, issuerUrl).href,
|
|
231
241
|
authorization_servers: [issuerUrl],
|
|
232
|
-
scopes_supported:
|
|
242
|
+
scopes_supported: SCOPES_SUPPORTED,
|
|
233
243
|
resource_documentation: 'https://github.com/elgorro/aiquila',
|
|
234
244
|
});
|
|
235
245
|
});
|
|
@@ -265,9 +275,44 @@ export async function startHttp() {
|
|
|
265
275
|
logger.warn({ status: res.statusCode, rpcMethod: req.body?.method }, '[mcp] Request rejected');
|
|
266
276
|
}
|
|
267
277
|
});
|
|
278
|
+
// The SDK appends `scope="..."` to the challenge only when
|
|
279
|
+
// `requiredScopes` is set — but setting that would also *enforce* the
|
|
280
|
+
// scope, and 403 every token issued before scopes were requested (plus
|
|
281
|
+
// the internal service token, which carries none). Advertise the scope
|
|
282
|
+
// without enforcing it by decorating the header on its way out.
|
|
283
|
+
const setHeader = res.setHeader.bind(res);
|
|
284
|
+
res.setHeader = (name, value) => {
|
|
285
|
+
if (typeof name === 'string' &&
|
|
286
|
+
name.toLowerCase() === 'www-authenticate' &&
|
|
287
|
+
typeof value === 'string' &&
|
|
288
|
+
!value.includes('scope=')) {
|
|
289
|
+
value = `${value}, scope="${CHALLENGE_SCOPE}"`;
|
|
290
|
+
}
|
|
291
|
+
return setHeader(name, value);
|
|
292
|
+
};
|
|
268
293
|
next();
|
|
269
294
|
},
|
|
270
|
-
|
|
295
|
+
// Lazy-auth gate: the handshake, capability listings, and public tools are
|
|
296
|
+
// served anonymously so clients can inspect the connector before sign-in.
|
|
297
|
+
// Everything else falls through to requireBearerAuth below.
|
|
298
|
+
// Omitted entirely when MCP_LAZY_AUTH=false, restoring a chain in which
|
|
299
|
+
// every JSON-RPC method requires a bearer token.
|
|
300
|
+
...(lazyAuthEnabled
|
|
301
|
+
? [
|
|
302
|
+
async (req, res, next) => {
|
|
303
|
+
if (!req.headers.authorization && isPublicRequest(req.body)) {
|
|
304
|
+
logger.debug({ rpcMethod: req.body?.method }, '[mcp] Public request (unauthenticated)');
|
|
305
|
+
await handleMcpRequest(req, res);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
next();
|
|
309
|
+
},
|
|
310
|
+
]
|
|
311
|
+
: []),
|
|
312
|
+
requireBearerAuth({
|
|
313
|
+
verifier: provider,
|
|
314
|
+
resourceMetadataUrl: new URL('/.well-known/oauth-protected-resource/mcp', issuerUrl).href,
|
|
315
|
+
}),
|
|
271
316
|
async (req, res) => {
|
|
272
317
|
logger.debug({ method: req.method, rpcMethod: req.body?.method }, '[mcp] Request received');
|
|
273
318
|
await handleMcpRequest(req, res);
|
|
@@ -288,6 +333,9 @@ export async function startHttp() {
|
|
|
288
333
|
if (authEnabled) {
|
|
289
334
|
const tp = process.env.MCP_TRUST_PROXY;
|
|
290
335
|
logger.info({ issuer: process.env.MCP_AUTH_ISSUER }, 'OAuth 2.0 authentication enabled');
|
|
336
|
+
logger.info({ lazyAuth: lazyAuthEnabled }, lazyAuthEnabled
|
|
337
|
+
? 'Lazy auth enabled — tools/list and public tools are readable without a token (set MCP_LAZY_AUTH=false to require one)'
|
|
338
|
+
: 'Lazy auth disabled — every JSON-RPC method requires a bearer token');
|
|
291
339
|
logger.info({ trustProxy: tp && tp !== 'false' ? tp : 'disabled' }, 'Trust proxy setting (set MCP_TRUST_PROXY=1 if behind a reverse proxy)');
|
|
292
340
|
if (hasStaticClient) {
|
|
293
341
|
logger.info({ clientId: process.env.MCP_CLIENT_ID }, 'Static OAuth client configured');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* Lazy authentication (mixed auth) for the Streamable HTTP transport.
|
|
4
|
+
*
|
|
5
|
+
* Unauthenticated clients may complete the MCP handshake, enumerate the server's
|
|
6
|
+
* capabilities, and call the tools listed in PUBLIC_TOOLS. Anything else is
|
|
7
|
+
* refused at the HTTP layer with a 401 + WWW-Authenticate challenge, which is
|
|
8
|
+
* what makes clients render an inline "Connect" card and retry the same call
|
|
9
|
+
* once the user has authorised.
|
|
10
|
+
*
|
|
11
|
+
* The refusal has to be an HTTP status, so the check must run *before* the
|
|
12
|
+
* JSON-RPC message reaches the MCP SDK: once a tool handler is executing, its
|
|
13
|
+
* return value is already destined to be wrapped in a 200 response, and a 200
|
|
14
|
+
* carrying `isError: true` reads as an application-level failure, not an auth
|
|
15
|
+
* challenge.
|
|
16
|
+
*
|
|
17
|
+
* @see https://claude.com/docs/connectors/building/lazy-authentication
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* JSON-RPC methods reachable without a token. These describe the server rather
|
|
21
|
+
* than touching the Nextcloud instance behind it, so they leak nothing.
|
|
22
|
+
*/
|
|
23
|
+
const PUBLIC_METHODS = new Set([
|
|
24
|
+
'initialize',
|
|
25
|
+
'notifications/initialized',
|
|
26
|
+
'notifications/cancelled',
|
|
27
|
+
'ping',
|
|
28
|
+
'tools/list',
|
|
29
|
+
'resources/list',
|
|
30
|
+
'resources/templates/list',
|
|
31
|
+
'prompts/list',
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Tools reachable without a token. Every other AIquila tool proxies to
|
|
35
|
+
* Nextcloud, so this set is deliberately tiny.
|
|
36
|
+
*/
|
|
37
|
+
const PUBLIC_TOOLS = new Set(['get_local_time']);
|
|
38
|
+
function isPublicMessage(msg) {
|
|
39
|
+
if (!msg || typeof msg !== 'object')
|
|
40
|
+
return false;
|
|
41
|
+
const method = msg.method;
|
|
42
|
+
if (typeof method !== 'string')
|
|
43
|
+
return false;
|
|
44
|
+
if (method === 'tools/call') {
|
|
45
|
+
const name = msg.params?.name;
|
|
46
|
+
return typeof name === 'string' && PUBLIC_TOOLS.has(name);
|
|
47
|
+
}
|
|
48
|
+
return PUBLIC_METHODS.has(method);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* True when every message in the request body is safe to serve anonymously.
|
|
52
|
+
*
|
|
53
|
+
* A JSON-RPC batch is a single HTTP request and therefore gets a single status
|
|
54
|
+
* code, so one protected message taints the whole batch. An empty or
|
|
55
|
+
* non-JSON-RPC body (a GET for the SSE stream, a DELETE to end a session) is
|
|
56
|
+
* never public — those still require a bearer token.
|
|
57
|
+
*/
|
|
58
|
+
export function isPublicRequest(body) {
|
|
59
|
+
const messages = Array.isArray(body) ? body : [body];
|
|
60
|
+
if (messages.length === 0)
|
|
61
|
+
return false;
|
|
62
|
+
return messages.every(isPublicMessage);
|
|
63
|
+
}
|