@aexol/spectral 0.2.10 → 0.2.12

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.
@@ -6,7 +6,7 @@
6
6
  import { auth as runSdkAuth, UnauthorizedError, } from "@modelcontextprotocol/sdk/client/auth.js";
7
7
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
8
  import open from "open";
9
- import { McpOAuthProvider } from "./mcp-oauth-provider.js";
9
+ import { McpOAuthProvider, setOAuthCallbackPort } from "./mcp-oauth-provider.js";
10
10
  import { ensureCallbackServer, waitForCallback, cancelPendingCallback, stopCallbackServer, } from "./mcp-callback-server.js";
11
11
  import { getAuthForUrl, isTokenExpired, hasStoredTokens, clearAllCredentials, updateOAuthState, getOAuthState, clearOAuthState, } from "./mcp-auth.js";
12
12
  // Track pending transports for auth completion
@@ -21,6 +21,22 @@ function generateState() {
21
21
  .map((b) => b.toString(16).padStart(2, "0"))
22
22
  .join("");
23
23
  }
24
+ /**
25
+ * Extract the port from a redirect URI. Returns undefined if no explicit port is set.
26
+ */
27
+ function parseRedirectPort(redirectUri) {
28
+ try {
29
+ const url = new URL(redirectUri);
30
+ if (url.port) {
31
+ return parseInt(url.port, 10);
32
+ }
33
+ // No explicit port - return undefined to use default
34
+ return undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
24
40
  /**
25
41
  * Extract OAuth configuration from a ServerEntry.
26
42
  */
@@ -34,6 +50,7 @@ function extractOAuthConfig(definition) {
34
50
  clientId: definition.oauth?.clientId,
35
51
  clientSecret: definition.oauth?.clientSecret,
36
52
  scope: definition.oauth?.scope,
53
+ redirectUri: definition.oauth?.redirectUri,
37
54
  };
38
55
  }
39
56
  /**
@@ -56,7 +73,16 @@ export async function startAuth(serverName, serverUrl, definition) {
56
73
  }
57
74
  // Start the callback server.
58
75
  // Pre-registered OAuth clients require an exact redirect URI, so enforce strict port binding.
59
- await ensureCallbackServer({ strictPort: Boolean(config.clientId) });
76
+ // When a custom redirectUri is specified, also use strict port binding and set the port.
77
+ const hasCustomRedirect = Boolean(config.redirectUri);
78
+ const redirectPort = hasCustomRedirect ? parseRedirectPort(config.redirectUri) : undefined;
79
+ if (redirectPort) {
80
+ setOAuthCallbackPort(redirectPort);
81
+ }
82
+ await ensureCallbackServer({
83
+ strictPort: Boolean(config.clientId) || hasCustomRedirect,
84
+ preferredPort: redirectPort,
85
+ });
60
86
  const oauthState = generateState();
61
87
  await updateOAuthState(serverName, oauthState);
62
88
  let capturedUrl;
@@ -5,7 +5,7 @@
5
5
  * Uses Node.js http module for compatibility.
6
6
  */
7
7
  import { createServer } from "http";
8
- import { OAUTH_CALLBACK_PATH, getConfiguredOAuthCallbackPort, getOAuthCallbackPort, setOAuthCallbackPort, } from "./mcp-oauth-provider.js";
8
+ import { getConfiguredOAuthCallbackPort, getOAuthCallbackPort, setOAuthCallbackPort, } from "./mcp-oauth-provider.js";
9
9
  // HTML templates for callback responses
10
10
  const HTML_SUCCESS = `<!DOCTYPE html>
11
11
  <html>
@@ -57,16 +57,17 @@ const MAX_PORT_SCAN_ATTEMPTS = 25;
57
57
  */
58
58
  function handleRequest(req, res) {
59
59
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
60
- // Only handle the callback path
61
- if (url.pathname !== OAUTH_CALLBACK_PATH) {
62
- res.writeHead(404, { "Content-Type": "text/plain" });
63
- res.end("Not found");
64
- return;
65
- }
66
60
  const code = url.searchParams.get("code");
67
61
  const state = url.searchParams.get("state");
68
62
  const error = url.searchParams.get("error");
69
63
  const errorDescription = url.searchParams.get("error_description");
64
+ // Accept callbacks on any path that carries OAuth query params.
65
+ // This supports custom redirectUri paths configured per-server.
66
+ if (!code && !state && !error) {
67
+ res.writeHead(404, { "Content-Type": "text/plain" });
68
+ res.end("Not found");
69
+ return;
70
+ }
70
71
  // Enforce state parameter presence for CSRF protection
71
72
  if (!state) {
72
73
  const errorMsg = "Missing required state parameter - potential CSRF attack";
@@ -116,7 +117,7 @@ function handleRequest(req, res) {
116
117
  * If strictPort is false, scans forward for an available local port.
117
118
  */
118
119
  export async function ensureCallbackServer(options = {}) {
119
- const configuredPort = getConfiguredOAuthCallbackPort();
120
+ const configuredPort = options.preferredPort ?? getConfiguredOAuthCallbackPort();
120
121
  const strictPort = options.strictPort === true;
121
122
  if (server) {
122
123
  if (!strictPort || getOAuthCallbackPort() === configuredPort)
@@ -46,11 +46,13 @@ export class McpOAuthProvider {
46
46
  }
47
47
  /**
48
48
  * The redirect URL for OAuth callbacks.
49
- * This must match the redirect_uri in client metadata.
49
+ * Uses configured redirectUri if provided, otherwise falls back to default.
50
50
  */
51
51
  get redirectUrl() {
52
52
  if (this.usesClientCredentials)
53
53
  return undefined;
54
+ if (this.config.redirectUri)
55
+ return this.config.redirectUri;
54
56
  return `http://localhost:${getOAuthCallbackPort()}${OAUTH_CALLBACK_PATH}`;
55
57
  }
56
58
  /**
@@ -43,33 +43,33 @@ export class McpServerManager {
43
43
  async createConnection(name, definition) {
44
44
  const client = this.createClient(name);
45
45
  let transport;
46
- if (definition.command) {
47
- let command = definition.command;
48
- let args = definition.args ?? [];
49
- if (command === "npx" || command === "npm") {
50
- const resolved = await resolveNpxBinary(command, args);
51
- if (resolved) {
52
- command = resolved.isJs ? "node" : resolved.binPath;
53
- args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs;
54
- logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`);
46
+ try {
47
+ if (definition.command) {
48
+ let command = definition.command;
49
+ let args = definition.args ?? [];
50
+ if (command === "npx" || command === "npm") {
51
+ const resolved = await resolveNpxBinary(command, args);
52
+ if (resolved) {
53
+ command = resolved.isJs ? "node" : resolved.binPath;
54
+ args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs;
55
+ logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`);
56
+ }
55
57
  }
58
+ transport = new StdioClientTransport({
59
+ command,
60
+ args,
61
+ env: resolveEnv(definition.env),
62
+ cwd: resolveConfigPath(definition.cwd),
63
+ stderr: definition.debug ? "inherit" : "ignore",
64
+ });
65
+ }
66
+ else if (definition.url) {
67
+ // HTTP transport with fallback
68
+ transport = await this.createHttpTransport(definition, name);
69
+ }
70
+ else {
71
+ throw new Error(`Server ${name} has no command or url`);
56
72
  }
57
- transport = new StdioClientTransport({
58
- command,
59
- args,
60
- env: resolveEnv(definition.env),
61
- cwd: resolveConfigPath(definition.cwd),
62
- stderr: definition.debug ? "inherit" : "ignore",
63
- });
64
- }
65
- else if (definition.url) {
66
- // HTTP transport with fallback
67
- transport = await this.createHttpTransport(definition, name);
68
- }
69
- else {
70
- throw new Error(`Server ${name} has no command or url`);
71
- }
72
- try {
73
73
  await client.connect(transport);
74
74
  this.attachAdapterNotificationHandlers(name, client);
75
75
  // Discover tools and resources
@@ -89,14 +89,17 @@ export class McpServerManager {
89
89
  };
90
90
  }
91
91
  catch (error) {
92
- // Check for UnauthorizedError - server requires OAuth
93
- if (error instanceof UnauthorizedError && supportsOAuth(definition)) {
94
- // Clean up both client and transport before reporting needs-auth.
95
- await client.close().catch(() => { });
92
+ // Clean up client and transport on any error.
93
+ await client.close().catch(() => { });
94
+ if (transport) {
96
95
  await transport.close().catch(() => { });
96
+ }
97
+ // Check for UnauthorizedError — server requires OAuth.
98
+ // This can fire from createHttpTransport probe or client.connect.
99
+ if (error instanceof UnauthorizedError && supportsOAuth(definition)) {
97
100
  return {
98
101
  client,
99
- transport,
102
+ transport: transport,
100
103
  definition,
101
104
  tools: [],
102
105
  resources: [],
@@ -105,9 +108,6 @@ export class McpServerManager {
105
108
  status: "needs-auth",
106
109
  };
107
110
  }
108
- // Clean up both client and transport on any error
109
- await client.close().catch(() => { });
110
- await transport.close().catch(() => { });
111
111
  throw error;
112
112
  }
113
113
  }
@@ -140,6 +140,7 @@ export class McpServerManager {
140
140
  clientId: definition.oauth?.clientId,
141
141
  clientSecret: definition.oauth?.clientSecret,
142
142
  scope: definition.oauth?.scope,
143
+ redirectUri: definition.oauth?.redirectUri,
143
144
  };
144
145
  authProvider = new McpOAuthProvider(serverName, definition.url, oauthConfig, {
145
146
  onRedirect: async (_authUrl) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,