@gleanwork/mcp-server-tester 0.12.0 → 1.0.0-beta.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/dist/cli/index.js CHANGED
@@ -13,11 +13,14 @@ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
13
13
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
14
14
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
15
15
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
16
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
16
17
  import { z } from 'zod';
17
18
  import createDebug from 'debug';
19
+ import { ProxyAgent, Agent } from 'undici';
20
+ import { readFileSync } from 'fs';
21
+ import * as oauth from 'oauth4webapi';
18
22
  import { homedir } from 'os';
19
23
  import * as http from 'http';
20
- import * as oauth from 'oauth4webapi';
21
24
 
22
25
  function Spinner({ label }) {
23
26
  return /* @__PURE__ */ jsxs(Box, { children: [
@@ -75,6 +78,10 @@ function JsonPreview({ data, maxLines = 15 }) {
75
78
  );
76
79
  }
77
80
 
81
+ // package.json
82
+ var package_default = {
83
+ version: "1.0.0-beta.1"};
84
+
78
85
  // src/cli/templates/index.ts
79
86
  function getPlaywrightConfigTemplate(answers) {
80
87
  const mcpConfig = answers.transport === "stdio" ? `{
@@ -110,7 +117,6 @@ export default defineConfig({
110
117
  ['html'],
111
118
  ['@gleanwork/mcp-server-tester/reporters/mcpReporter', {
112
119
  outputDir: '.mcp-test-results',
113
- autoOpen: true,
114
120
  historyLimit: 10
115
121
  }]
116
122
  ],
@@ -251,7 +257,7 @@ function getPackageJsonTemplate(projectName) {
251
257
  "dependencies": {
252
258
  "@modelcontextprotocol/sdk": "^1.0.4",
253
259
  "@playwright/test": "^1.49.0",
254
- "@gleanwork/mcp-server-tester": "^0.9.0",
260
+ "@gleanwork/mcp-server-tester": "^${package_default.version}",
255
261
  "zod": "^3.24.1"
256
262
  },
257
263
  "devDependencies": {
@@ -539,9 +545,16 @@ var MCPOAuthConfigSchema = z.object({
539
545
  clientSecret: z.string().optional(),
540
546
  redirectUri: z.string().url().optional()
541
547
  });
548
+ var MCPClientCredentialsConfigSchema = z.object({
549
+ clientId: z.string().optional(),
550
+ clientSecret: z.string().optional(),
551
+ tokenEndpoint: z.string().url("tokenEndpoint must be a valid URL").optional(),
552
+ scopes: z.array(z.string()).optional()
553
+ });
542
554
  var MCPAuthConfigSchema = z.object({
543
555
  accessToken: z.string().optional(),
544
- oauth: MCPOAuthConfigSchema.optional()
556
+ oauth: MCPOAuthConfigSchema.optional(),
557
+ clientCredentials: MCPClientCredentialsConfigSchema.optional()
545
558
  }).refine(
546
559
  (data) => !(data.accessToken && data.oauth),
547
560
  "Cannot specify both accessToken and oauth configuration"
@@ -551,19 +564,48 @@ var StdioConfigSchema = z.object({
551
564
  command: z.string().min(1, "command is required for stdio transport"),
552
565
  args: z.array(z.string()).optional(),
553
566
  cwd: z.string().optional(),
567
+ env: z.record(z.string(), z.string()).optional(),
554
568
  capabilities: MCPHostCapabilitiesSchema.optional(),
555
569
  connectTimeoutMs: z.number().positive().optional(),
556
570
  requestTimeoutMs: z.number().positive().optional(),
571
+ callTimeoutMs: z.number().positive().optional(),
557
572
  quiet: z.boolean().optional()
558
573
  });
574
+ function isLocalhost(hostname) {
575
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
576
+ }
559
577
  var HttpConfigSchema = z.object({
560
578
  transport: z.literal("http"),
561
- serverUrl: z.string().url("serverUrl must be a valid URL"),
579
+ serverUrl: z.string().url("serverUrl must be a valid URL").refine((url) => {
580
+ let parsed;
581
+ try {
582
+ parsed = new URL(url);
583
+ } catch {
584
+ return true;
585
+ }
586
+ if (parsed.protocol === "http:" && !isLocalhost(parsed.hostname)) {
587
+ console.warn(
588
+ `[mcp-server-tester] serverUrl uses http:// for non-localhost address "${parsed.hostname}". This transmits tokens unencrypted. Use https:// for remote servers.`
589
+ );
590
+ }
591
+ return true;
592
+ }),
562
593
  headers: z.record(z.string()).optional(),
563
594
  capabilities: MCPHostCapabilitiesSchema.optional(),
564
595
  connectTimeoutMs: z.number().positive().optional(),
565
596
  requestTimeoutMs: z.number().positive().optional(),
566
- auth: MCPAuthConfigSchema.optional()
597
+ callTimeoutMs: z.number().positive().optional(),
598
+ auth: MCPAuthConfigSchema.optional(),
599
+ proxy: z.object({
600
+ url: z.string().url("proxy.url must be a valid URL")
601
+ }).optional(),
602
+ retryAttempts: z.number().int().min(0).optional(),
603
+ tls: z.object({
604
+ ca: z.string().optional(),
605
+ cert: z.string().optional(),
606
+ key: z.string().optional(),
607
+ rejectUnauthorized: z.boolean().optional()
608
+ }).optional()
567
609
  });
568
610
  var MCPConfigSchema = z.discriminatedUnion("transport", [
569
611
  StdioConfigSchema,
@@ -573,26 +615,241 @@ function validateMCPConfig(config) {
573
615
  return MCPConfigSchema.parse(config);
574
616
  }
575
617
  function isStdioConfig(config) {
576
- return config.transport === "stdio" && typeof config.command === "string";
618
+ return config.transport === "stdio";
577
619
  }
578
620
  function isHttpConfig(config) {
579
- return config.transport === "http" && typeof config.serverUrl === "string";
621
+ return config.transport === "http";
580
622
  }
581
623
  var NAMESPACE = "mcp-server-tester";
582
624
  var debugClient = createDebug(`${NAMESPACE}:client`);
583
625
  createDebug(`${NAMESPACE}:oauth`);
584
626
  createDebug(`${NAMESPACE}:eval`);
627
+ var debugHttp = createDebug(`${NAMESPACE}:http`);
628
+ var debug = createDebug("mcp-server-tester:oauth-flow");
629
+ async function generatePKCE() {
630
+ const codeVerifier = oauth.generateRandomCodeVerifier();
631
+ const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
632
+ return {
633
+ codeVerifier,
634
+ codeChallenge
635
+ };
636
+ }
637
+ function generateState() {
638
+ return oauth.generateRandomState();
639
+ }
640
+ function buildAuthorizationUrl(config) {
641
+ const authorizationEndpoint = config.authServer.server.authorization_endpoint;
642
+ if (!authorizationEndpoint) {
643
+ throw new Error(
644
+ "Authorization server does not have an authorization_endpoint"
645
+ );
646
+ }
647
+ const authorizationUrl = new URL(authorizationEndpoint);
648
+ authorizationUrl.searchParams.set("client_id", config.clientId);
649
+ authorizationUrl.searchParams.set("redirect_uri", config.redirectUri);
650
+ authorizationUrl.searchParams.set("response_type", "code");
651
+ authorizationUrl.searchParams.set("scope", config.scopes.join(" "));
652
+ authorizationUrl.searchParams.set("code_challenge", config.codeChallenge);
653
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
654
+ authorizationUrl.searchParams.set("state", config.state);
655
+ if (config.resource) {
656
+ authorizationUrl.searchParams.set("resource", config.resource);
657
+ }
658
+ return authorizationUrl;
659
+ }
660
+ async function exchangeCodeForTokens(config) {
661
+ const client = {
662
+ client_id: config.clientId,
663
+ token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
664
+ };
665
+ const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
666
+ const callbackUrl = new URL(config.redirectUri);
667
+ callbackUrl.searchParams.set("code", config.code);
668
+ callbackUrl.searchParams.set("state", config.state);
669
+ const validatedParams = oauth.validateAuthResponse(
670
+ config.authServer.server,
671
+ client,
672
+ callbackUrl,
673
+ config.state
674
+ );
675
+ const response = await oauth.authorizationCodeGrantRequest(
676
+ config.authServer.server,
677
+ client,
678
+ clientAuth,
679
+ validatedParams,
680
+ config.redirectUri,
681
+ config.codeVerifier
682
+ );
683
+ const result = await oauth.processAuthorizationCodeResponse(
684
+ config.authServer.server,
685
+ client,
686
+ response
687
+ );
688
+ return {
689
+ accessToken: result.access_token,
690
+ tokenType: result.token_type,
691
+ expiresIn: result.expires_in,
692
+ refreshToken: result.refresh_token,
693
+ scope: result.scope
694
+ };
695
+ }
696
+ async function refreshAccessToken(config) {
697
+ const client = {
698
+ client_id: config.clientId,
699
+ token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
700
+ };
701
+ const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
702
+ const response = await oauth.refreshTokenGrantRequest(
703
+ config.authServer.server,
704
+ client,
705
+ clientAuth,
706
+ config.refreshToken
707
+ );
708
+ if (!response.ok) {
709
+ const contentType = response.headers.get("content-type") ?? "";
710
+ let errorMessage = `Token refresh failed: ${response.status} ${response.statusText}`;
711
+ try {
712
+ if (contentType.includes("application/json")) {
713
+ const errorBody = await response.clone().json();
714
+ if (errorBody.error) {
715
+ errorMessage = `Token refresh failed: ${errorBody.error}`;
716
+ if (errorBody.error_description) {
717
+ errorMessage += ` - ${errorBody.error_description}`;
718
+ }
719
+ }
720
+ } else {
721
+ const textBody = await response.clone().text();
722
+ if (textBody) {
723
+ errorMessage = `Token refresh failed: ${response.status} - ${textBody}`;
724
+ }
725
+ }
726
+ } catch {
727
+ }
728
+ throw new Error(errorMessage);
729
+ }
730
+ const result = await oauth.processRefreshTokenResponse(
731
+ config.authServer.server,
732
+ client,
733
+ response
734
+ );
735
+ return {
736
+ accessToken: result.access_token,
737
+ tokenType: result.token_type,
738
+ expiresIn: result.expires_in,
739
+ refreshToken: result.refresh_token,
740
+ scope: result.scope
741
+ };
742
+ }
743
+ async function performClientCredentialsFlow(config) {
744
+ const tokenEndpointUrl = new URL(config.tokenEndpoint);
745
+ const authServer = {
746
+ issuer: tokenEndpointUrl.origin,
747
+ token_endpoint: config.tokenEndpoint
748
+ };
749
+ const client = {
750
+ client_id: config.clientId
751
+ };
752
+ const clientAuth = oauth.ClientSecretBasic(config.clientSecret);
753
+ const parameters = {};
754
+ if (config.scopes && config.scopes.length > 0) {
755
+ parameters["scope"] = config.scopes.join(" ");
756
+ }
757
+ const response = await oauth.clientCredentialsGrantRequest(
758
+ authServer,
759
+ client,
760
+ clientAuth,
761
+ parameters
762
+ );
763
+ const result = await oauth.processClientCredentialsResponse(
764
+ authServer,
765
+ client,
766
+ response
767
+ );
768
+ const requestedScopes = new Set(
769
+ config.scopes && config.scopes.length > 0 ? config.scopes : []
770
+ );
771
+ const grantedScopes = new Set(
772
+ (result.scope ?? "").split(" ").filter(Boolean)
773
+ );
774
+ const missingScopes = [...requestedScopes].filter(
775
+ (s) => !grantedScopes.has(s)
776
+ );
777
+ if (missingScopes.length > 0 && requestedScopes.size > 0 && grantedScopes.size > 0) {
778
+ debug(
779
+ "[oauth] Warning: Token server granted fewer scopes than requested. Missing: %s",
780
+ missingScopes.join(", ")
781
+ );
782
+ }
783
+ return {
784
+ accessToken: result.access_token,
785
+ tokenType: result.token_type,
786
+ expiresIn: result.expires_in,
787
+ scope: result.scope
788
+ };
789
+ }
585
790
 
586
791
  // src/mcp/clientFactory.ts
792
+ function getRetryAfterDelayMs(err) {
793
+ const response = err?.response;
794
+ const retryAfter = response?.headers?.get?.("Retry-After");
795
+ if (retryAfter) {
796
+ const seconds = parseInt(retryAfter, 10);
797
+ if (!isNaN(seconds)) return seconds * 1e3;
798
+ }
799
+ return null;
800
+ }
801
+ function isRateLimitError(err) {
802
+ const response = err?.response;
803
+ return response?.status === 429;
804
+ }
805
+ function isTransientNetworkError(err) {
806
+ if (!(err instanceof Error)) return false;
807
+ const msg = err.message.toLowerCase();
808
+ return msg.includes("econnreset") || msg.includes("econnrefused") || msg.includes("etimedout") || msg.includes("enotfound") || msg.includes("network") || msg.includes("socket hang up") || msg.includes("fetch failed");
809
+ }
810
+ function isRetryableError(err) {
811
+ return isTransientNetworkError(err) || isRateLimitError(err);
812
+ }
813
+ async function retryWithBackoff(fn, maxAttempts) {
814
+ let lastErr;
815
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
816
+ try {
817
+ return await fn();
818
+ } catch (err) {
819
+ lastErr = err;
820
+ if (attempt < maxAttempts && isRetryableError(err)) {
821
+ const retryAfterMs = getRetryAfterDelayMs(err);
822
+ const delayMs = retryAfterMs !== null ? retryAfterMs : Math.min(1e3 * 2 ** attempt, 3e4);
823
+ debugClient(
824
+ "Retryable error on attempt %d/%d, retrying in %dms: %s",
825
+ attempt + 1,
826
+ maxAttempts + 1,
827
+ delayMs,
828
+ err.message
829
+ );
830
+ await new Promise((resolve3) => setTimeout(resolve3, delayMs));
831
+ } else {
832
+ throw err;
833
+ }
834
+ }
835
+ }
836
+ throw lastErr;
837
+ }
838
+ var agentRegistry = /* @__PURE__ */ new WeakMap();
587
839
  async function createMCPClientForConfig(config, options) {
588
840
  const validatedConfig = validateMCPConfig(config);
589
841
  const client = new Client(
590
842
  {
591
843
  name: "@gleanwork/mcp-server-tester",
592
- version: "0.1.0"
844
+ version: package_default.version
593
845
  },
594
846
  {
595
- capabilities: validatedConfig.capabilities ?? {}
847
+ capabilities: {
848
+ ...validatedConfig.capabilities ?? {},
849
+ // Only advertise sampling if a handler has been registered;
850
+ // declaring sampling capability without a handler violates the MCP spec
851
+ sampling: void 0
852
+ }
596
853
  }
597
854
  );
598
855
  if (isStdioConfig(validatedConfig)) {
@@ -601,33 +858,140 @@ async function createMCPClientForConfig(config, options) {
601
858
  args: validatedConfig.args ?? [],
602
859
  ...validatedConfig.cwd && { cwd: validatedConfig.cwd },
603
860
  // Suppress server stderr when quiet mode is enabled
604
- ...validatedConfig.quiet && { stderr: "ignore" }
861
+ ...validatedConfig.quiet && { stderr: "ignore" },
862
+ ...validatedConfig.env && {
863
+ env: Object.fromEntries(
864
+ Object.entries({ ...process.env, ...validatedConfig.env }).filter(
865
+ (entry) => entry[1] !== void 0
866
+ )
867
+ )
868
+ }
605
869
  });
606
870
  debugClient("Connecting via stdio: %O", {
607
871
  command: validatedConfig.command,
608
872
  args: validatedConfig.args,
609
873
  cwd: validatedConfig.cwd
610
874
  });
611
- await client.connect(transport);
875
+ await client.connect(
876
+ transport,
877
+ validatedConfig.connectTimeoutMs !== void 0 ? { timeout: validatedConfig.connectTimeoutMs } : void 0
878
+ );
612
879
  } else if (isHttpConfig(validatedConfig)) {
613
880
  const headers = { ...validatedConfig.headers };
881
+ if (validatedConfig.auth?.clientCredentials && true) {
882
+ const ccConfig = validatedConfig.auth.clientCredentials;
883
+ const clientId = ccConfig.clientId ?? process.env["MCP_CLIENT_ID"];
884
+ const clientSecret = ccConfig.clientSecret ?? process.env["MCP_CLIENT_SECRET"];
885
+ if (!clientId || !clientSecret) {
886
+ throw new Error(
887
+ "Client credentials require clientId/clientSecret in config or MCP_CLIENT_ID/MCP_CLIENT_SECRET env vars"
888
+ );
889
+ }
890
+ if (!ccConfig.tokenEndpoint) {
891
+ throw new Error(
892
+ "Client credentials require tokenEndpoint in auth.clientCredentials config"
893
+ );
894
+ }
895
+ debugClient("Fetching token via client credentials grant");
896
+ const tokenResult = await performClientCredentialsFlow({
897
+ tokenEndpoint: ccConfig.tokenEndpoint,
898
+ clientId,
899
+ clientSecret,
900
+ scopes: ccConfig.scopes
901
+ });
902
+ headers.Authorization = `Bearer ${tokenResult.accessToken}`;
903
+ }
614
904
  if (validatedConfig.auth?.accessToken && true) {
615
905
  headers.Authorization = `Bearer ${validatedConfig.auth.accessToken}`;
616
906
  }
617
- const transport = new StreamableHTTPClientTransport(
618
- new URL(validatedConfig.serverUrl),
619
- {
620
- requestInit: Object.keys(headers).length > 0 ? { headers } : void 0,
621
- // Pass auth provider for OAuth flow - MCP SDK handles it automatically
622
- authProvider: options?.authProvider
907
+ const url = new URL(validatedConfig.serverUrl);
908
+ let requestInit = Object.keys(headers).length > 0 ? { headers } : void 0;
909
+ const proxyUrl = validatedConfig.proxy?.url ?? process.env["HTTPS_PROXY"] ?? process.env["HTTP_PROXY"];
910
+ if (proxyUrl) {
911
+ const proxyAgent = new ProxyAgent(proxyUrl);
912
+ try {
913
+ const sanitized = new URL(proxyUrl);
914
+ debugClient(
915
+ "Using proxy: %s://%s:%s",
916
+ sanitized.protocol.slice(0, -1),
917
+ sanitized.hostname,
918
+ sanitized.port
919
+ );
920
+ } catch {
921
+ debugClient("Using proxy (unparseable URL)");
922
+ }
923
+ requestInit = {
924
+ ...requestInit,
925
+ dispatcher: proxyAgent
926
+ };
927
+ }
928
+ if (validatedConfig.tls) {
929
+ const tlsCfg = validatedConfig.tls;
930
+ try {
931
+ const dispatcher = new Agent({
932
+ connect: {
933
+ ...tlsCfg.ca && { ca: readFileSync(tlsCfg.ca) },
934
+ ...tlsCfg.cert && { cert: readFileSync(tlsCfg.cert) },
935
+ ...tlsCfg.key && { key: readFileSync(tlsCfg.key) },
936
+ rejectUnauthorized: tlsCfg.rejectUnauthorized ?? true
937
+ }
938
+ });
939
+ agentRegistry.set(client, dispatcher);
940
+ requestInit = {
941
+ ...requestInit,
942
+ dispatcher
943
+ };
944
+ debugClient("TLS configuration applied");
945
+ } catch (error) {
946
+ const filePath = tlsCfg.ca ?? tlsCfg.cert ?? tlsCfg.key;
947
+ const fileType = tlsCfg.ca ? "CA certificate" : tlsCfg.cert ? "client certificate" : "client key";
948
+ throw new Error(
949
+ `Failed to load TLS ${fileType} from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
950
+ );
623
951
  }
624
- );
952
+ } else if (proxyUrl) {
953
+ const existingDispatcher = requestInit?.dispatcher;
954
+ if (existingDispatcher) {
955
+ agentRegistry.set(client, existingDispatcher);
956
+ }
957
+ }
625
958
  debugClient("Connecting via HTTP: %O", {
626
959
  serverUrl: validatedConfig.serverUrl,
627
960
  headers: Object.keys(headers).length > 0 ? Object.keys(headers) : void 0,
628
961
  hasAuthProvider: false
629
962
  });
630
- await client.connect(transport);
963
+ debugHttp("Connecting to %s", validatedConfig.serverUrl);
964
+ if (Object.keys(headers).length > 0) {
965
+ debugHttp("Request header names: %O", Object.keys(headers));
966
+ }
967
+ const retryAttempts = validatedConfig.retryAttempts ?? 0;
968
+ const connectOptions = validatedConfig.connectTimeoutMs !== void 0 ? { timeout: validatedConfig.connectTimeoutMs } : void 0;
969
+ await retryWithBackoff(async () => {
970
+ try {
971
+ debugHttp("Attempting transport: streamableHttp");
972
+ const streamableTransport = new StreamableHTTPClientTransport(url, {
973
+ requestInit,
974
+ authProvider: options?.authProvider
975
+ });
976
+ await client.connect(streamableTransport, connectOptions);
977
+ debugClient("Connected via Streamable HTTP");
978
+ debugHttp("Connection established via streamableHttp");
979
+ } catch (err) {
980
+ debugHttp(
981
+ "streamableHttp failed (%s), falling back to SSE",
982
+ err.message
983
+ );
984
+ debugClient("Streamable HTTP failed, falling back to SSE transport");
985
+ debugHttp("Attempting transport: sse");
986
+ const sseTransport = new SSEClientTransport(url, {
987
+ requestInit,
988
+ authProvider: options?.authProvider
989
+ });
990
+ await client.connect(sseTransport, connectOptions);
991
+ debugClient("Connected via SSE");
992
+ debugHttp("Connection established via sse");
993
+ }
994
+ }, retryAttempts);
631
995
  }
632
996
  debugClient("Connected successfully");
633
997
  const serverInfo = client.getServerVersion();
@@ -640,8 +1004,24 @@ async function closeMCPClient(client) {
640
1004
  try {
641
1005
  await client.close();
642
1006
  } catch (error) {
643
- console.error("[MCP] Error closing client:", error);
1007
+ debugClient(
1008
+ "Error closing client: %s",
1009
+ error instanceof Error ? error.message : String(error)
1010
+ );
644
1011
  throw error;
1012
+ } finally {
1013
+ const agent = agentRegistry.get(client);
1014
+ if (agent) {
1015
+ agentRegistry.delete(client);
1016
+ try {
1017
+ await agent.close();
1018
+ } catch (agentError) {
1019
+ debugClient(
1020
+ "Error closing undici agent: %s",
1021
+ agentError.message
1022
+ );
1023
+ }
1024
+ }
645
1025
  }
646
1026
  }
647
1027
  var ENV_VAR_NAMES = {
@@ -820,119 +1200,27 @@ var FileOAuthStorage = class {
820
1200
  }
821
1201
  }
822
1202
  };
823
- async function generatePKCE() {
824
- const codeVerifier = oauth.generateRandomCodeVerifier();
825
- const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
826
- return {
827
- codeVerifier,
828
- codeChallenge
829
- };
830
- }
831
- function generateState() {
832
- return oauth.generateRandomState();
833
- }
834
- function buildAuthorizationUrl(config) {
835
- const authorizationEndpoint = config.authServer.server.authorization_endpoint;
836
- if (!authorizationEndpoint) {
837
- throw new Error(
838
- "Authorization server does not have an authorization_endpoint"
839
- );
840
- }
841
- const authorizationUrl = new URL(authorizationEndpoint);
842
- authorizationUrl.searchParams.set("client_id", config.clientId);
843
- authorizationUrl.searchParams.set("redirect_uri", config.redirectUri);
844
- authorizationUrl.searchParams.set("response_type", "code");
845
- authorizationUrl.searchParams.set("scope", config.scopes.join(" "));
846
- authorizationUrl.searchParams.set("code_challenge", config.codeChallenge);
847
- authorizationUrl.searchParams.set("code_challenge_method", "S256");
848
- authorizationUrl.searchParams.set("state", config.state);
849
- if (config.resource) {
850
- authorizationUrl.searchParams.set("resource", config.resource);
1203
+ function isLocalhostUrl(url) {
1204
+ try {
1205
+ const parsed = new URL(url);
1206
+ const h = parsed.hostname;
1207
+ return h === "localhost" || h === "127.0.0.1" || h === "::1";
1208
+ } catch {
1209
+ return false;
851
1210
  }
852
- return authorizationUrl;
853
- }
854
- async function exchangeCodeForTokens(config) {
855
- const client = {
856
- client_id: config.clientId,
857
- token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
858
- };
859
- const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
860
- const callbackUrl = new URL(config.redirectUri);
861
- callbackUrl.searchParams.set("code", config.code);
862
- callbackUrl.searchParams.set("state", config.state);
863
- const validatedParams = oauth.validateAuthResponse(
864
- config.authServer.server,
865
- client,
866
- callbackUrl,
867
- config.state
868
- );
869
- const response = await oauth.authorizationCodeGrantRequest(
870
- config.authServer.server,
871
- client,
872
- clientAuth,
873
- validatedParams,
874
- config.redirectUri,
875
- config.codeVerifier
876
- );
877
- const result = await oauth.processAuthorizationCodeResponse(
878
- config.authServer.server,
879
- client,
880
- response
881
- );
882
- return {
883
- accessToken: result.access_token,
884
- tokenType: result.token_type,
885
- expiresIn: result.expires_in,
886
- refreshToken: result.refresh_token,
887
- scope: result.scope
888
- };
889
1211
  }
890
- async function refreshAccessToken(config) {
891
- const client = {
892
- client_id: config.clientId,
893
- token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
894
- };
895
- const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
896
- const response = await oauth.refreshTokenGrantRequest(
897
- config.authServer.server,
898
- client,
899
- clientAuth,
900
- config.refreshToken
901
- );
902
- if (!response.ok) {
903
- const contentType = response.headers.get("content-type") ?? "";
904
- let errorMessage = `Token refresh failed: ${response.status} ${response.statusText}`;
905
- try {
906
- if (contentType.includes("application/json")) {
907
- const errorBody = await response.clone().json();
908
- if (errorBody.error) {
909
- errorMessage = `Token refresh failed: ${errorBody.error}`;
910
- if (errorBody.error_description) {
911
- errorMessage += ` - ${errorBody.error_description}`;
912
- }
913
- }
914
- } else {
915
- const textBody = await response.clone().text();
916
- if (textBody) {
917
- errorMessage = `Token refresh failed: ${response.status} - ${textBody}`;
918
- }
919
- }
920
- } catch {
1212
+ function validateAuthServerEndpoints(authServer) {
1213
+ const endpoints = [
1214
+ { name: "authorization_endpoint", url: authServer.authorization_endpoint },
1215
+ { name: "token_endpoint", url: authServer.token_endpoint }
1216
+ ];
1217
+ for (const { name, url } of endpoints) {
1218
+ if (url && !url.startsWith("https://") && !isLocalhostUrl(url)) {
1219
+ throw new Error(
1220
+ `OAuth discovery returned an insecure ${name}: "${url}". Only HTTPS endpoints are permitted for OAuth flows to prevent token interception.`
1221
+ );
921
1222
  }
922
- throw new Error(errorMessage);
923
1223
  }
924
- const result = await oauth.processRefreshTokenResponse(
925
- config.authServer.server,
926
- client,
927
- response
928
- );
929
- return {
930
- accessToken: result.access_token,
931
- tokenType: result.token_type,
932
- expiresIn: result.expires_in,
933
- refreshToken: result.refresh_token,
934
- scope: result.scope
935
- };
936
1224
  }
937
1225
  var MCP_PROTOCOL_VERSION = "2025-06-18";
938
1226
  async function discoverProtectedResource(mcpServerUrl) {
@@ -1002,6 +1290,7 @@ async function discoverAuthorizationServer(authServerUrl) {
1002
1290
  })
1003
1291
  });
1004
1292
  const metadata = await oauth.processDiscoveryResponse(issuer, response);
1293
+ validateAuthServerEndpoints(metadata);
1005
1294
  return {
1006
1295
  server: metadata,
1007
1296
  issuer: authServerUrl
@@ -1009,7 +1298,7 @@ async function discoverAuthorizationServer(authServerUrl) {
1009
1298
  }
1010
1299
 
1011
1300
  // src/auth/cli.ts
1012
- var debug = createDebug("mcp-server-tester:cli-oauth");
1301
+ var debug2 = createDebug("mcp-server-tester:cli-oauth");
1013
1302
  var DEFAULT_TIMEOUT_MS = 3e5;
1014
1303
  var DEFAULT_CLIENT_NAME = "@gleanwork/mcp-server-tester";
1015
1304
  var DEFAULT_METADATA_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -1035,7 +1324,7 @@ var CLIOAuthClient = class {
1035
1324
  async getAccessToken() {
1036
1325
  const envTokens = loadTokensFromEnv();
1037
1326
  if (envTokens) {
1038
- debug("Using tokens from environment variables");
1327
+ debug2("Using tokens from environment variables");
1039
1328
  return {
1040
1329
  accessToken: envTokens.accessToken,
1041
1330
  tokenType: envTokens.tokenType,
@@ -1048,7 +1337,7 @@ var CLIOAuthClient = class {
1048
1337
  if (storedTokens?.accessToken) {
1049
1338
  const isValid = await this.storage.hasValidToken();
1050
1339
  if (isValid) {
1051
- debug("Using cached tokens from storage");
1340
+ debug2("Using cached tokens from storage");
1052
1341
  return {
1053
1342
  accessToken: storedTokens.accessToken,
1054
1343
  tokenType: storedTokens.tokenType,
@@ -1058,7 +1347,7 @@ var CLIOAuthClient = class {
1058
1347
  };
1059
1348
  }
1060
1349
  if (storedTokens.refreshToken) {
1061
- debug("Token expired, attempting refresh");
1350
+ debug2("Token expired, attempting refresh");
1062
1351
  try {
1063
1352
  const refreshedTokens = await this.refreshStoredToken(storedTokens);
1064
1353
  return {
@@ -1069,11 +1358,11 @@ var CLIOAuthClient = class {
1069
1358
  fromEnv: false
1070
1359
  };
1071
1360
  } catch (error) {
1072
- debug("Token refresh failed, will re-authenticate:", error);
1361
+ debug2("Token refresh failed, will re-authenticate:", error);
1073
1362
  }
1074
1363
  }
1075
1364
  }
1076
- debug("Performing full OAuth authentication");
1365
+ debug2("Performing full OAuth authentication");
1077
1366
  return this.authenticate();
1078
1367
  }
1079
1368
  /**
@@ -1089,7 +1378,7 @@ var CLIOAuthClient = class {
1089
1378
  async tryGetAccessToken() {
1090
1379
  const envTokens = loadTokensFromEnv();
1091
1380
  if (envTokens) {
1092
- debug("Using tokens from environment variables");
1381
+ debug2("Using tokens from environment variables");
1093
1382
  return {
1094
1383
  accessToken: envTokens.accessToken,
1095
1384
  tokenType: envTokens.tokenType,
@@ -1102,7 +1391,7 @@ var CLIOAuthClient = class {
1102
1391
  if (storedTokens?.accessToken) {
1103
1392
  const isValid = await this.storage.hasValidToken();
1104
1393
  if (isValid) {
1105
- debug("Using cached tokens from storage");
1394
+ debug2("Using cached tokens from storage");
1106
1395
  return {
1107
1396
  accessToken: storedTokens.accessToken,
1108
1397
  tokenType: storedTokens.tokenType,
@@ -1112,7 +1401,7 @@ var CLIOAuthClient = class {
1112
1401
  };
1113
1402
  }
1114
1403
  if (storedTokens.refreshToken) {
1115
- debug("Token expired, attempting refresh");
1404
+ debug2("Token expired, attempting refresh");
1116
1405
  try {
1117
1406
  const refreshedTokens = await this.refreshStoredToken(storedTokens);
1118
1407
  return {
@@ -1123,12 +1412,12 @@ var CLIOAuthClient = class {
1123
1412
  fromEnv: false
1124
1413
  };
1125
1414
  } catch (error) {
1126
- debug("Token refresh failed:", error);
1415
+ debug2("Token refresh failed:", error);
1127
1416
  return null;
1128
1417
  }
1129
1418
  }
1130
1419
  }
1131
- debug("No valid token available");
1420
+ debug2("No valid token available");
1132
1421
  return null;
1133
1422
  }
1134
1423
  /**
@@ -1163,7 +1452,7 @@ var CLIOAuthClient = class {
1163
1452
  */
1164
1453
  async clearCredentials() {
1165
1454
  await this.storage.deleteTokens();
1166
- debug("Cleared stored credentials");
1455
+ debug2("Cleared stored credentials");
1167
1456
  }
1168
1457
  /**
1169
1458
  * Discover protected resource and authorization server
@@ -1173,12 +1462,12 @@ var CLIOAuthClient = class {
1173
1462
  if (cachedMetadata) {
1174
1463
  const age = Date.now() - cachedMetadata.discoveredAt;
1175
1464
  if (age < DEFAULT_METADATA_TTL_MS) {
1176
- debug("Using cached server metadata (age: %dms)", age);
1177
- debug(
1465
+ debug2("Using cached server metadata (age: %dms)", age);
1466
+ debug2(
1178
1467
  "Cached protected resource scopes: %O",
1179
1468
  cachedMetadata.protectedResource.scopes_supported
1180
1469
  );
1181
- debug(
1470
+ debug2(
1182
1471
  "Cached auth server scopes: %O",
1183
1472
  cachedMetadata.authServer.server.scopes_supported
1184
1473
  );
@@ -1187,12 +1476,12 @@ var CLIOAuthClient = class {
1187
1476
  authServer: cachedMetadata.authServer
1188
1477
  };
1189
1478
  }
1190
- debug("Cached server metadata is stale (age: %dms), re-discovering", age);
1479
+ debug2("Cached server metadata is stale (age: %dms), re-discovering", age);
1191
1480
  }
1192
- debug("Discovering protected resource:", this.config.mcpServerUrl);
1481
+ debug2("Discovering protected resource:", this.config.mcpServerUrl);
1193
1482
  const prResult = await discoverProtectedResource(this.config.mcpServerUrl);
1194
- debug("Found protected resource:", prResult.metadata.resource);
1195
- debug(
1483
+ debug2("Found protected resource:", prResult.metadata.resource);
1484
+ debug2(
1196
1485
  "Protected resource scopes_supported: %O",
1197
1486
  prResult.metadata.scopes_supported
1198
1487
  );
@@ -1202,10 +1491,10 @@ var CLIOAuthClient = class {
1202
1491
  "No authorization servers found in protected resource metadata"
1203
1492
  );
1204
1493
  }
1205
- debug("Discovering authorization server:", authServerUrl);
1494
+ debug2("Discovering authorization server:", authServerUrl);
1206
1495
  const authServer = await discoverAuthorizationServer(authServerUrl);
1207
- debug("Found authorization server:", authServer.issuer);
1208
- debug(
1496
+ debug2("Found authorization server:", authServer.issuer);
1497
+ debug2(
1209
1498
  "Auth server scopes_supported: %O",
1210
1499
  authServer.server.scopes_supported
1211
1500
  );
@@ -1225,7 +1514,7 @@ var CLIOAuthClient = class {
1225
1514
  */
1226
1515
  async getOrRegisterClient(authServer) {
1227
1516
  if (this.config.clientId) {
1228
- debug("Using pre-configured client ID");
1517
+ debug2("Using pre-configured client ID");
1229
1518
  return {
1230
1519
  clientId: this.config.clientId,
1231
1520
  clientSecret: this.config.clientSecret
@@ -1233,10 +1522,10 @@ var CLIOAuthClient = class {
1233
1522
  }
1234
1523
  const cachedClient = await this.storage.loadClient();
1235
1524
  if (cachedClient?.clientId) {
1236
- debug("Using cached client registration");
1525
+ debug2("Using cached client registration");
1237
1526
  return cachedClient;
1238
1527
  }
1239
- debug("Registering new client via DCR");
1528
+ debug2("Registering new client via DCR");
1240
1529
  const client = await this.registerClient(authServer);
1241
1530
  await this.storage.saveClient(client);
1242
1531
  return client;
@@ -1274,7 +1563,7 @@ ${errorText}`
1274
1563
  );
1275
1564
  }
1276
1565
  const data = await response.json();
1277
- debug("Client registered:", data.client_id);
1566
+ debug2("Client registered:", data.client_id);
1278
1567
  return {
1279
1568
  clientId: data.client_id,
1280
1569
  clientSecret: data.client_secret,
@@ -1292,17 +1581,17 @@ ${errorText}`
1292
1581
  const redirectUri = `http://127.0.0.1:${port}/callback`;
1293
1582
  try {
1294
1583
  const requestedScopes = this.config.scopes ?? protectedResource.scopes_supported ?? authServer.server.scopes_supported ?? ["openid"];
1295
- debug("Scope resolution:");
1296
- debug(" - User config scopes: %O", this.config.scopes);
1297
- debug(
1584
+ debug2("Scope resolution:");
1585
+ debug2(" - User config scopes: %O", this.config.scopes);
1586
+ debug2(
1298
1587
  " - Protected resource scopes_supported: %O",
1299
1588
  protectedResource.scopes_supported
1300
1589
  );
1301
- debug(
1590
+ debug2(
1302
1591
  " - Auth server scopes_supported: %O",
1303
1592
  authServer.server.scopes_supported
1304
1593
  );
1305
- debug(" - Final requested scopes: %O", requestedScopes);
1594
+ debug2(" - Final requested scopes: %O", requestedScopes);
1306
1595
  const authUrl = buildAuthorizationUrl({
1307
1596
  authServer,
1308
1597
  clientId: client.clientId,
@@ -1312,16 +1601,19 @@ ${errorText}`
1312
1601
  state,
1313
1602
  resource: protectedResource.resource
1314
1603
  });
1315
- debug("Authorization URL: %s", authUrl.toString());
1316
- debug("Authorization URL params:");
1317
- debug(" - client_id: %s", authUrl.searchParams.get("client_id"));
1318
- debug(" - redirect_uri: %s", authUrl.searchParams.get("redirect_uri"));
1319
- debug(" - scope: %s", authUrl.searchParams.get("scope"));
1320
- debug(" - resource: %s", authUrl.searchParams.get("resource"));
1604
+ debug2(
1605
+ "Authorization URL (base): %s",
1606
+ `${authUrl.origin}${authUrl.pathname}`
1607
+ );
1608
+ debug2("Authorization URL params:");
1609
+ debug2(" - client_id: %s", authUrl.searchParams.get("client_id"));
1610
+ debug2(" - redirect_uri: %s", authUrl.searchParams.get("redirect_uri"));
1611
+ debug2(" - scope: %s", authUrl.searchParams.get("scope"));
1612
+ debug2(" - resource: %s", authUrl.searchParams.get("resource"));
1321
1613
  await this.openBrowserOrPrintUrl(authUrl);
1322
- debug("Waiting for OAuth callback...");
1614
+ debug2("Waiting for OAuth callback...");
1323
1615
  const code = await codePromise;
1324
- debug("Received authorization code");
1616
+ debug2("Received authorization code");
1325
1617
  const tokenResult = await exchangeCodeForTokens({
1326
1618
  authServer,
1327
1619
  clientId: client.clientId,
@@ -1359,14 +1651,14 @@ ${errorText}`
1359
1651
  let clientId;
1360
1652
  let clientSecret;
1361
1653
  if (storedTokens.clientId) {
1362
- debug("Using clientId from stored tokens for refresh");
1654
+ debug2("Using clientId from stored tokens for refresh");
1363
1655
  clientId = storedTokens.clientId;
1364
1656
  const storedClient = await this.storage.loadClient();
1365
1657
  if (storedClient?.clientId === clientId) {
1366
1658
  clientSecret = storedClient.clientSecret;
1367
1659
  }
1368
1660
  } else {
1369
- debug(
1661
+ debug2(
1370
1662
  "No clientId in stored tokens, falling back to stored client (legacy behavior)"
1371
1663
  );
1372
1664
  const client = await this.getOrRegisterClient(metadata.authServer);
@@ -1460,7 +1752,7 @@ ${errorText}`
1460
1752
  const preferredPort = this.config.callbackPort ?? 0;
1461
1753
  server.listen(preferredPort, "127.0.0.1", () => {
1462
1754
  const address = server.address();
1463
- debug("Callback server listening on port", address.port);
1755
+ debug2("Callback server listening on port", address.port);
1464
1756
  resolve3({ port: address.port, codePromise, close: forceClose });
1465
1757
  });
1466
1758
  server.on("error", (err) => {
@@ -1484,9 +1776,9 @@ ${errorText}`
1484
1776
  try {
1485
1777
  const open = await import('open');
1486
1778
  await open.default(url.toString());
1487
- debug("Opened browser for authentication");
1779
+ debug2("Opened browser for authentication");
1488
1780
  } catch (error) {
1489
- debug("Failed to open browser:", error);
1781
+ debug2("Failed to open browser:", error);
1490
1782
  console.log("\nFailed to open browser automatically.");
1491
1783
  console.log("Please open the following URL manually:\n");
1492
1784
  console.log(url.toString() + "\n");
@@ -1697,7 +1989,7 @@ function isCallToolResult(value) {
1697
1989
  return false;
1698
1990
  }
1699
1991
  const v = value;
1700
- return Array.isArray(v.content) || typeof v.isError === "boolean";
1992
+ return Array.isArray(v.content);
1701
1993
  }
1702
1994
  function extractTextFromContentArray(content) {
1703
1995
  const textParts = [];
@@ -2770,7 +3062,7 @@ async function token(serverUrl, options) {
2770
3062
 
2771
3063
  // src/cli/index.ts
2772
3064
  var program = new Command();
2773
- program.name("mcp-server-tester").description("CLI tools for MCP server evaluation and testing").version("0.1.0");
3065
+ program.name("mcp-server-tester").description("CLI tools for MCP server evaluation and testing").version(package_default.version);
2774
3066
  program.command("init").description("Initialize a new MCP evaluation project").option("-n, --name <name>", "Project name").option("-d, --dir <directory>", "Target directory", ".").action(init);
2775
3067
  program.command("generate").alias("gen").description("Generate eval dataset by interacting with MCP server").option("-c, --config <path>", "Path to MCP config").option("-o, --output <path>", "Output dataset path", "data/dataset.json").option("-s, --snapshot", "Use Playwright snapshot testing for all cases").action(generate);
2776
3068
  program.command("login").description("Authenticate with an MCP server via OAuth").argument("<server-url>", "MCP server URL to authenticate with").option("--force", "Force re-authentication even if valid token exists").option("--state-dir <dir>", "Custom directory for token storage").option(