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.
@@ -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
- throw new Error('Invalid or expired access token');
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,
@@ -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: ['mcp:tools', 'mcp:resources', 'mcp:prompts'],
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: ['mcp:tools', 'mcp:resources', 'mcp:prompts'],
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
- requireBearerAuth({ verifier: provider }),
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.3.29",
3
+ "version": "0.3.30",
4
4
  "description": "Nextcloud MCP server — files, calendar, contacts, mail, maps, notes, tasks & 120+ more tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",