@gleanwork/mcp-server-tester 0.12.0 → 1.0.0-beta.0

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.0"};
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"
@@ -554,16 +567,44 @@ var StdioConfigSchema = z.object({
554
567
  capabilities: MCPHostCapabilitiesSchema.optional(),
555
568
  connectTimeoutMs: z.number().positive().optional(),
556
569
  requestTimeoutMs: z.number().positive().optional(),
570
+ callTimeoutMs: z.number().positive().optional(),
557
571
  quiet: z.boolean().optional()
558
572
  });
573
+ function isLocalhost(hostname) {
574
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
575
+ }
559
576
  var HttpConfigSchema = z.object({
560
577
  transport: z.literal("http"),
561
- serverUrl: z.string().url("serverUrl must be a valid URL"),
578
+ serverUrl: z.string().url("serverUrl must be a valid URL").refine((url) => {
579
+ let parsed;
580
+ try {
581
+ parsed = new URL(url);
582
+ } catch {
583
+ return true;
584
+ }
585
+ if (parsed.protocol === "http:" && !isLocalhost(parsed.hostname)) {
586
+ console.warn(
587
+ `[mcp-server-tester] serverUrl uses http:// for non-localhost address "${parsed.hostname}". This transmits tokens unencrypted. Use https:// for remote servers.`
588
+ );
589
+ }
590
+ return true;
591
+ }),
562
592
  headers: z.record(z.string()).optional(),
563
593
  capabilities: MCPHostCapabilitiesSchema.optional(),
564
594
  connectTimeoutMs: z.number().positive().optional(),
565
595
  requestTimeoutMs: z.number().positive().optional(),
566
- auth: MCPAuthConfigSchema.optional()
596
+ callTimeoutMs: z.number().positive().optional(),
597
+ auth: MCPAuthConfigSchema.optional(),
598
+ proxy: z.object({
599
+ url: z.string().url("proxy.url must be a valid URL")
600
+ }).optional(),
601
+ retryAttempts: z.number().int().min(0).optional(),
602
+ tls: z.object({
603
+ ca: z.string().optional(),
604
+ cert: z.string().optional(),
605
+ key: z.string().optional(),
606
+ rejectUnauthorized: z.boolean().optional()
607
+ }).optional()
567
608
  });
568
609
  var MCPConfigSchema = z.discriminatedUnion("transport", [
569
610
  StdioConfigSchema,
@@ -573,26 +614,241 @@ function validateMCPConfig(config) {
573
614
  return MCPConfigSchema.parse(config);
574
615
  }
575
616
  function isStdioConfig(config) {
576
- return config.transport === "stdio" && typeof config.command === "string";
617
+ return config.transport === "stdio";
577
618
  }
578
619
  function isHttpConfig(config) {
579
- return config.transport === "http" && typeof config.serverUrl === "string";
620
+ return config.transport === "http";
580
621
  }
581
622
  var NAMESPACE = "mcp-server-tester";
582
623
  var debugClient = createDebug(`${NAMESPACE}:client`);
583
624
  createDebug(`${NAMESPACE}:oauth`);
584
625
  createDebug(`${NAMESPACE}:eval`);
626
+ var debugHttp = createDebug(`${NAMESPACE}:http`);
627
+ var debug = createDebug("mcp-server-tester:oauth-flow");
628
+ async function generatePKCE() {
629
+ const codeVerifier = oauth.generateRandomCodeVerifier();
630
+ const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
631
+ return {
632
+ codeVerifier,
633
+ codeChallenge
634
+ };
635
+ }
636
+ function generateState() {
637
+ return oauth.generateRandomState();
638
+ }
639
+ function buildAuthorizationUrl(config) {
640
+ const authorizationEndpoint = config.authServer.server.authorization_endpoint;
641
+ if (!authorizationEndpoint) {
642
+ throw new Error(
643
+ "Authorization server does not have an authorization_endpoint"
644
+ );
645
+ }
646
+ const authorizationUrl = new URL(authorizationEndpoint);
647
+ authorizationUrl.searchParams.set("client_id", config.clientId);
648
+ authorizationUrl.searchParams.set("redirect_uri", config.redirectUri);
649
+ authorizationUrl.searchParams.set("response_type", "code");
650
+ authorizationUrl.searchParams.set("scope", config.scopes.join(" "));
651
+ authorizationUrl.searchParams.set("code_challenge", config.codeChallenge);
652
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
653
+ authorizationUrl.searchParams.set("state", config.state);
654
+ if (config.resource) {
655
+ authorizationUrl.searchParams.set("resource", config.resource);
656
+ }
657
+ return authorizationUrl;
658
+ }
659
+ async function exchangeCodeForTokens(config) {
660
+ const client = {
661
+ client_id: config.clientId,
662
+ token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
663
+ };
664
+ const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
665
+ const callbackUrl = new URL(config.redirectUri);
666
+ callbackUrl.searchParams.set("code", config.code);
667
+ callbackUrl.searchParams.set("state", config.state);
668
+ const validatedParams = oauth.validateAuthResponse(
669
+ config.authServer.server,
670
+ client,
671
+ callbackUrl,
672
+ config.state
673
+ );
674
+ const response = await oauth.authorizationCodeGrantRequest(
675
+ config.authServer.server,
676
+ client,
677
+ clientAuth,
678
+ validatedParams,
679
+ config.redirectUri,
680
+ config.codeVerifier
681
+ );
682
+ const result = await oauth.processAuthorizationCodeResponse(
683
+ config.authServer.server,
684
+ client,
685
+ response
686
+ );
687
+ return {
688
+ accessToken: result.access_token,
689
+ tokenType: result.token_type,
690
+ expiresIn: result.expires_in,
691
+ refreshToken: result.refresh_token,
692
+ scope: result.scope
693
+ };
694
+ }
695
+ async function refreshAccessToken(config) {
696
+ const client = {
697
+ client_id: config.clientId,
698
+ token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
699
+ };
700
+ const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
701
+ const response = await oauth.refreshTokenGrantRequest(
702
+ config.authServer.server,
703
+ client,
704
+ clientAuth,
705
+ config.refreshToken
706
+ );
707
+ if (!response.ok) {
708
+ const contentType = response.headers.get("content-type") ?? "";
709
+ let errorMessage = `Token refresh failed: ${response.status} ${response.statusText}`;
710
+ try {
711
+ if (contentType.includes("application/json")) {
712
+ const errorBody = await response.clone().json();
713
+ if (errorBody.error) {
714
+ errorMessage = `Token refresh failed: ${errorBody.error}`;
715
+ if (errorBody.error_description) {
716
+ errorMessage += ` - ${errorBody.error_description}`;
717
+ }
718
+ }
719
+ } else {
720
+ const textBody = await response.clone().text();
721
+ if (textBody) {
722
+ errorMessage = `Token refresh failed: ${response.status} - ${textBody}`;
723
+ }
724
+ }
725
+ } catch {
726
+ }
727
+ throw new Error(errorMessage);
728
+ }
729
+ const result = await oauth.processRefreshTokenResponse(
730
+ config.authServer.server,
731
+ client,
732
+ response
733
+ );
734
+ return {
735
+ accessToken: result.access_token,
736
+ tokenType: result.token_type,
737
+ expiresIn: result.expires_in,
738
+ refreshToken: result.refresh_token,
739
+ scope: result.scope
740
+ };
741
+ }
742
+ async function performClientCredentialsFlow(config) {
743
+ const tokenEndpointUrl = new URL(config.tokenEndpoint);
744
+ const authServer = {
745
+ issuer: tokenEndpointUrl.origin,
746
+ token_endpoint: config.tokenEndpoint
747
+ };
748
+ const client = {
749
+ client_id: config.clientId
750
+ };
751
+ const clientAuth = oauth.ClientSecretBasic(config.clientSecret);
752
+ const parameters = {};
753
+ if (config.scopes && config.scopes.length > 0) {
754
+ parameters["scope"] = config.scopes.join(" ");
755
+ }
756
+ const response = await oauth.clientCredentialsGrantRequest(
757
+ authServer,
758
+ client,
759
+ clientAuth,
760
+ parameters
761
+ );
762
+ const result = await oauth.processClientCredentialsResponse(
763
+ authServer,
764
+ client,
765
+ response
766
+ );
767
+ const requestedScopes = new Set(
768
+ config.scopes && config.scopes.length > 0 ? config.scopes : []
769
+ );
770
+ const grantedScopes = new Set(
771
+ (result.scope ?? "").split(" ").filter(Boolean)
772
+ );
773
+ const missingScopes = [...requestedScopes].filter(
774
+ (s) => !grantedScopes.has(s)
775
+ );
776
+ if (missingScopes.length > 0 && requestedScopes.size > 0 && grantedScopes.size > 0) {
777
+ debug(
778
+ "[oauth] Warning: Token server granted fewer scopes than requested. Missing: %s",
779
+ missingScopes.join(", ")
780
+ );
781
+ }
782
+ return {
783
+ accessToken: result.access_token,
784
+ tokenType: result.token_type,
785
+ expiresIn: result.expires_in,
786
+ scope: result.scope
787
+ };
788
+ }
585
789
 
586
790
  // src/mcp/clientFactory.ts
791
+ function getRetryAfterDelayMs(err) {
792
+ const response = err?.response;
793
+ const retryAfter = response?.headers?.get?.("Retry-After");
794
+ if (retryAfter) {
795
+ const seconds = parseInt(retryAfter, 10);
796
+ if (!isNaN(seconds)) return seconds * 1e3;
797
+ }
798
+ return null;
799
+ }
800
+ function isRateLimitError(err) {
801
+ const response = err?.response;
802
+ return response?.status === 429;
803
+ }
804
+ function isTransientNetworkError(err) {
805
+ if (!(err instanceof Error)) return false;
806
+ const msg = err.message.toLowerCase();
807
+ 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");
808
+ }
809
+ function isRetryableError(err) {
810
+ return isTransientNetworkError(err) || isRateLimitError(err);
811
+ }
812
+ async function retryWithBackoff(fn, maxAttempts) {
813
+ let lastErr;
814
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
815
+ try {
816
+ return await fn();
817
+ } catch (err) {
818
+ lastErr = err;
819
+ if (attempt < maxAttempts && isRetryableError(err)) {
820
+ const retryAfterMs = getRetryAfterDelayMs(err);
821
+ const delayMs = retryAfterMs !== null ? retryAfterMs : Math.min(1e3 * 2 ** attempt, 3e4);
822
+ debugClient(
823
+ "Retryable error on attempt %d/%d, retrying in %dms: %s",
824
+ attempt + 1,
825
+ maxAttempts + 1,
826
+ delayMs,
827
+ err.message
828
+ );
829
+ await new Promise((resolve3) => setTimeout(resolve3, delayMs));
830
+ } else {
831
+ throw err;
832
+ }
833
+ }
834
+ }
835
+ throw lastErr;
836
+ }
837
+ var agentRegistry = /* @__PURE__ */ new WeakMap();
587
838
  async function createMCPClientForConfig(config, options) {
588
839
  const validatedConfig = validateMCPConfig(config);
589
840
  const client = new Client(
590
841
  {
591
842
  name: "@gleanwork/mcp-server-tester",
592
- version: "0.1.0"
843
+ version: package_default.version
593
844
  },
594
845
  {
595
- capabilities: validatedConfig.capabilities ?? {}
846
+ capabilities: {
847
+ ...validatedConfig.capabilities ?? {},
848
+ // Only advertise sampling if a handler has been registered;
849
+ // declaring sampling capability without a handler violates the MCP spec
850
+ sampling: void 0
851
+ }
596
852
  }
597
853
  );
598
854
  if (isStdioConfig(validatedConfig)) {
@@ -608,26 +864,126 @@ async function createMCPClientForConfig(config, options) {
608
864
  args: validatedConfig.args,
609
865
  cwd: validatedConfig.cwd
610
866
  });
611
- await client.connect(transport);
867
+ await client.connect(
868
+ transport,
869
+ validatedConfig.connectTimeoutMs !== void 0 ? { timeout: validatedConfig.connectTimeoutMs } : void 0
870
+ );
612
871
  } else if (isHttpConfig(validatedConfig)) {
613
872
  const headers = { ...validatedConfig.headers };
873
+ if (validatedConfig.auth?.clientCredentials && true) {
874
+ const ccConfig = validatedConfig.auth.clientCredentials;
875
+ const clientId = ccConfig.clientId ?? process.env["MCP_CLIENT_ID"];
876
+ const clientSecret = ccConfig.clientSecret ?? process.env["MCP_CLIENT_SECRET"];
877
+ if (!clientId || !clientSecret) {
878
+ throw new Error(
879
+ "Client credentials require clientId/clientSecret in config or MCP_CLIENT_ID/MCP_CLIENT_SECRET env vars"
880
+ );
881
+ }
882
+ if (!ccConfig.tokenEndpoint) {
883
+ throw new Error(
884
+ "Client credentials require tokenEndpoint in auth.clientCredentials config"
885
+ );
886
+ }
887
+ debugClient("Fetching token via client credentials grant");
888
+ const tokenResult = await performClientCredentialsFlow({
889
+ tokenEndpoint: ccConfig.tokenEndpoint,
890
+ clientId,
891
+ clientSecret,
892
+ scopes: ccConfig.scopes
893
+ });
894
+ headers.Authorization = `Bearer ${tokenResult.accessToken}`;
895
+ }
614
896
  if (validatedConfig.auth?.accessToken && true) {
615
897
  headers.Authorization = `Bearer ${validatedConfig.auth.accessToken}`;
616
898
  }
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
899
+ const url = new URL(validatedConfig.serverUrl);
900
+ let requestInit = Object.keys(headers).length > 0 ? { headers } : void 0;
901
+ const proxyUrl = validatedConfig.proxy?.url ?? process.env["HTTPS_PROXY"] ?? process.env["HTTP_PROXY"];
902
+ if (proxyUrl) {
903
+ const proxyAgent = new ProxyAgent(proxyUrl);
904
+ try {
905
+ const sanitized = new URL(proxyUrl);
906
+ debugClient(
907
+ "Using proxy: %s://%s:%s",
908
+ sanitized.protocol.slice(0, -1),
909
+ sanitized.hostname,
910
+ sanitized.port
911
+ );
912
+ } catch {
913
+ debugClient("Using proxy (unparseable URL)");
623
914
  }
624
- );
915
+ requestInit = {
916
+ ...requestInit,
917
+ dispatcher: proxyAgent
918
+ };
919
+ }
920
+ if (validatedConfig.tls) {
921
+ const tlsCfg = validatedConfig.tls;
922
+ try {
923
+ const dispatcher = new Agent({
924
+ connect: {
925
+ ...tlsCfg.ca && { ca: readFileSync(tlsCfg.ca) },
926
+ ...tlsCfg.cert && { cert: readFileSync(tlsCfg.cert) },
927
+ ...tlsCfg.key && { key: readFileSync(tlsCfg.key) },
928
+ rejectUnauthorized: tlsCfg.rejectUnauthorized ?? true
929
+ }
930
+ });
931
+ agentRegistry.set(client, dispatcher);
932
+ requestInit = {
933
+ ...requestInit,
934
+ dispatcher
935
+ };
936
+ debugClient("TLS configuration applied");
937
+ } catch (error) {
938
+ const filePath = tlsCfg.ca ?? tlsCfg.cert ?? tlsCfg.key;
939
+ const fileType = tlsCfg.ca ? "CA certificate" : tlsCfg.cert ? "client certificate" : "client key";
940
+ throw new Error(
941
+ `Failed to load TLS ${fileType} from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
942
+ );
943
+ }
944
+ } else if (proxyUrl) {
945
+ const existingDispatcher = requestInit?.dispatcher;
946
+ if (existingDispatcher) {
947
+ agentRegistry.set(client, existingDispatcher);
948
+ }
949
+ }
625
950
  debugClient("Connecting via HTTP: %O", {
626
951
  serverUrl: validatedConfig.serverUrl,
627
952
  headers: Object.keys(headers).length > 0 ? Object.keys(headers) : void 0,
628
953
  hasAuthProvider: false
629
954
  });
630
- await client.connect(transport);
955
+ debugHttp("Connecting to %s", validatedConfig.serverUrl);
956
+ if (Object.keys(headers).length > 0) {
957
+ debugHttp("Request header names: %O", Object.keys(headers));
958
+ }
959
+ const retryAttempts = validatedConfig.retryAttempts ?? 0;
960
+ const connectOptions = validatedConfig.connectTimeoutMs !== void 0 ? { timeout: validatedConfig.connectTimeoutMs } : void 0;
961
+ await retryWithBackoff(async () => {
962
+ try {
963
+ debugHttp("Attempting transport: streamableHttp");
964
+ const streamableTransport = new StreamableHTTPClientTransport(url, {
965
+ requestInit,
966
+ authProvider: options?.authProvider
967
+ });
968
+ await client.connect(streamableTransport, connectOptions);
969
+ debugClient("Connected via Streamable HTTP");
970
+ debugHttp("Connection established via streamableHttp");
971
+ } catch (err) {
972
+ debugHttp(
973
+ "streamableHttp failed (%s), falling back to SSE",
974
+ err.message
975
+ );
976
+ debugClient("Streamable HTTP failed, falling back to SSE transport");
977
+ debugHttp("Attempting transport: sse");
978
+ const sseTransport = new SSEClientTransport(url, {
979
+ requestInit,
980
+ authProvider: options?.authProvider
981
+ });
982
+ await client.connect(sseTransport, connectOptions);
983
+ debugClient("Connected via SSE");
984
+ debugHttp("Connection established via sse");
985
+ }
986
+ }, retryAttempts);
631
987
  }
632
988
  debugClient("Connected successfully");
633
989
  const serverInfo = client.getServerVersion();
@@ -642,6 +998,19 @@ async function closeMCPClient(client) {
642
998
  } catch (error) {
643
999
  console.error("[MCP] Error closing client:", error);
644
1000
  throw error;
1001
+ } finally {
1002
+ const agent = agentRegistry.get(client);
1003
+ if (agent) {
1004
+ agentRegistry.delete(client);
1005
+ try {
1006
+ await agent.close();
1007
+ } catch (agentError) {
1008
+ debugClient(
1009
+ "Error closing undici agent: %s",
1010
+ agentError.message
1011
+ );
1012
+ }
1013
+ }
645
1014
  }
646
1015
  }
647
1016
  var ENV_VAR_NAMES = {
@@ -820,119 +1189,27 @@ var FileOAuthStorage = class {
820
1189
  }
821
1190
  }
822
1191
  };
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);
1192
+ function isLocalhostUrl(url) {
1193
+ try {
1194
+ const parsed = new URL(url);
1195
+ const h = parsed.hostname;
1196
+ return h === "localhost" || h === "127.0.0.1" || h === "::1";
1197
+ } catch {
1198
+ return false;
851
1199
  }
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
1200
  }
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 {
1201
+ function validateAuthServerEndpoints(authServer) {
1202
+ const endpoints = [
1203
+ { name: "authorization_endpoint", url: authServer.authorization_endpoint },
1204
+ { name: "token_endpoint", url: authServer.token_endpoint }
1205
+ ];
1206
+ for (const { name, url } of endpoints) {
1207
+ if (url && !url.startsWith("https://") && !isLocalhostUrl(url)) {
1208
+ throw new Error(
1209
+ `OAuth discovery returned an insecure ${name}: "${url}". Only HTTPS endpoints are permitted for OAuth flows to prevent token interception.`
1210
+ );
921
1211
  }
922
- throw new Error(errorMessage);
923
1212
  }
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
1213
  }
937
1214
  var MCP_PROTOCOL_VERSION = "2025-06-18";
938
1215
  async function discoverProtectedResource(mcpServerUrl) {
@@ -1002,6 +1279,7 @@ async function discoverAuthorizationServer(authServerUrl) {
1002
1279
  })
1003
1280
  });
1004
1281
  const metadata = await oauth.processDiscoveryResponse(issuer, response);
1282
+ validateAuthServerEndpoints(metadata);
1005
1283
  return {
1006
1284
  server: metadata,
1007
1285
  issuer: authServerUrl
@@ -1009,7 +1287,7 @@ async function discoverAuthorizationServer(authServerUrl) {
1009
1287
  }
1010
1288
 
1011
1289
  // src/auth/cli.ts
1012
- var debug = createDebug("mcp-server-tester:cli-oauth");
1290
+ var debug2 = createDebug("mcp-server-tester:cli-oauth");
1013
1291
  var DEFAULT_TIMEOUT_MS = 3e5;
1014
1292
  var DEFAULT_CLIENT_NAME = "@gleanwork/mcp-server-tester";
1015
1293
  var DEFAULT_METADATA_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -1035,7 +1313,7 @@ var CLIOAuthClient = class {
1035
1313
  async getAccessToken() {
1036
1314
  const envTokens = loadTokensFromEnv();
1037
1315
  if (envTokens) {
1038
- debug("Using tokens from environment variables");
1316
+ debug2("Using tokens from environment variables");
1039
1317
  return {
1040
1318
  accessToken: envTokens.accessToken,
1041
1319
  tokenType: envTokens.tokenType,
@@ -1048,7 +1326,7 @@ var CLIOAuthClient = class {
1048
1326
  if (storedTokens?.accessToken) {
1049
1327
  const isValid = await this.storage.hasValidToken();
1050
1328
  if (isValid) {
1051
- debug("Using cached tokens from storage");
1329
+ debug2("Using cached tokens from storage");
1052
1330
  return {
1053
1331
  accessToken: storedTokens.accessToken,
1054
1332
  tokenType: storedTokens.tokenType,
@@ -1058,7 +1336,7 @@ var CLIOAuthClient = class {
1058
1336
  };
1059
1337
  }
1060
1338
  if (storedTokens.refreshToken) {
1061
- debug("Token expired, attempting refresh");
1339
+ debug2("Token expired, attempting refresh");
1062
1340
  try {
1063
1341
  const refreshedTokens = await this.refreshStoredToken(storedTokens);
1064
1342
  return {
@@ -1069,11 +1347,11 @@ var CLIOAuthClient = class {
1069
1347
  fromEnv: false
1070
1348
  };
1071
1349
  } catch (error) {
1072
- debug("Token refresh failed, will re-authenticate:", error);
1350
+ debug2("Token refresh failed, will re-authenticate:", error);
1073
1351
  }
1074
1352
  }
1075
1353
  }
1076
- debug("Performing full OAuth authentication");
1354
+ debug2("Performing full OAuth authentication");
1077
1355
  return this.authenticate();
1078
1356
  }
1079
1357
  /**
@@ -1089,7 +1367,7 @@ var CLIOAuthClient = class {
1089
1367
  async tryGetAccessToken() {
1090
1368
  const envTokens = loadTokensFromEnv();
1091
1369
  if (envTokens) {
1092
- debug("Using tokens from environment variables");
1370
+ debug2("Using tokens from environment variables");
1093
1371
  return {
1094
1372
  accessToken: envTokens.accessToken,
1095
1373
  tokenType: envTokens.tokenType,
@@ -1102,7 +1380,7 @@ var CLIOAuthClient = class {
1102
1380
  if (storedTokens?.accessToken) {
1103
1381
  const isValid = await this.storage.hasValidToken();
1104
1382
  if (isValid) {
1105
- debug("Using cached tokens from storage");
1383
+ debug2("Using cached tokens from storage");
1106
1384
  return {
1107
1385
  accessToken: storedTokens.accessToken,
1108
1386
  tokenType: storedTokens.tokenType,
@@ -1112,7 +1390,7 @@ var CLIOAuthClient = class {
1112
1390
  };
1113
1391
  }
1114
1392
  if (storedTokens.refreshToken) {
1115
- debug("Token expired, attempting refresh");
1393
+ debug2("Token expired, attempting refresh");
1116
1394
  try {
1117
1395
  const refreshedTokens = await this.refreshStoredToken(storedTokens);
1118
1396
  return {
@@ -1123,12 +1401,12 @@ var CLIOAuthClient = class {
1123
1401
  fromEnv: false
1124
1402
  };
1125
1403
  } catch (error) {
1126
- debug("Token refresh failed:", error);
1404
+ debug2("Token refresh failed:", error);
1127
1405
  return null;
1128
1406
  }
1129
1407
  }
1130
1408
  }
1131
- debug("No valid token available");
1409
+ debug2("No valid token available");
1132
1410
  return null;
1133
1411
  }
1134
1412
  /**
@@ -1163,7 +1441,7 @@ var CLIOAuthClient = class {
1163
1441
  */
1164
1442
  async clearCredentials() {
1165
1443
  await this.storage.deleteTokens();
1166
- debug("Cleared stored credentials");
1444
+ debug2("Cleared stored credentials");
1167
1445
  }
1168
1446
  /**
1169
1447
  * Discover protected resource and authorization server
@@ -1173,12 +1451,12 @@ var CLIOAuthClient = class {
1173
1451
  if (cachedMetadata) {
1174
1452
  const age = Date.now() - cachedMetadata.discoveredAt;
1175
1453
  if (age < DEFAULT_METADATA_TTL_MS) {
1176
- debug("Using cached server metadata (age: %dms)", age);
1177
- debug(
1454
+ debug2("Using cached server metadata (age: %dms)", age);
1455
+ debug2(
1178
1456
  "Cached protected resource scopes: %O",
1179
1457
  cachedMetadata.protectedResource.scopes_supported
1180
1458
  );
1181
- debug(
1459
+ debug2(
1182
1460
  "Cached auth server scopes: %O",
1183
1461
  cachedMetadata.authServer.server.scopes_supported
1184
1462
  );
@@ -1187,12 +1465,12 @@ var CLIOAuthClient = class {
1187
1465
  authServer: cachedMetadata.authServer
1188
1466
  };
1189
1467
  }
1190
- debug("Cached server metadata is stale (age: %dms), re-discovering", age);
1468
+ debug2("Cached server metadata is stale (age: %dms), re-discovering", age);
1191
1469
  }
1192
- debug("Discovering protected resource:", this.config.mcpServerUrl);
1470
+ debug2("Discovering protected resource:", this.config.mcpServerUrl);
1193
1471
  const prResult = await discoverProtectedResource(this.config.mcpServerUrl);
1194
- debug("Found protected resource:", prResult.metadata.resource);
1195
- debug(
1472
+ debug2("Found protected resource:", prResult.metadata.resource);
1473
+ debug2(
1196
1474
  "Protected resource scopes_supported: %O",
1197
1475
  prResult.metadata.scopes_supported
1198
1476
  );
@@ -1202,10 +1480,10 @@ var CLIOAuthClient = class {
1202
1480
  "No authorization servers found in protected resource metadata"
1203
1481
  );
1204
1482
  }
1205
- debug("Discovering authorization server:", authServerUrl);
1483
+ debug2("Discovering authorization server:", authServerUrl);
1206
1484
  const authServer = await discoverAuthorizationServer(authServerUrl);
1207
- debug("Found authorization server:", authServer.issuer);
1208
- debug(
1485
+ debug2("Found authorization server:", authServer.issuer);
1486
+ debug2(
1209
1487
  "Auth server scopes_supported: %O",
1210
1488
  authServer.server.scopes_supported
1211
1489
  );
@@ -1225,7 +1503,7 @@ var CLIOAuthClient = class {
1225
1503
  */
1226
1504
  async getOrRegisterClient(authServer) {
1227
1505
  if (this.config.clientId) {
1228
- debug("Using pre-configured client ID");
1506
+ debug2("Using pre-configured client ID");
1229
1507
  return {
1230
1508
  clientId: this.config.clientId,
1231
1509
  clientSecret: this.config.clientSecret
@@ -1233,10 +1511,10 @@ var CLIOAuthClient = class {
1233
1511
  }
1234
1512
  const cachedClient = await this.storage.loadClient();
1235
1513
  if (cachedClient?.clientId) {
1236
- debug("Using cached client registration");
1514
+ debug2("Using cached client registration");
1237
1515
  return cachedClient;
1238
1516
  }
1239
- debug("Registering new client via DCR");
1517
+ debug2("Registering new client via DCR");
1240
1518
  const client = await this.registerClient(authServer);
1241
1519
  await this.storage.saveClient(client);
1242
1520
  return client;
@@ -1274,7 +1552,7 @@ ${errorText}`
1274
1552
  );
1275
1553
  }
1276
1554
  const data = await response.json();
1277
- debug("Client registered:", data.client_id);
1555
+ debug2("Client registered:", data.client_id);
1278
1556
  return {
1279
1557
  clientId: data.client_id,
1280
1558
  clientSecret: data.client_secret,
@@ -1292,17 +1570,17 @@ ${errorText}`
1292
1570
  const redirectUri = `http://127.0.0.1:${port}/callback`;
1293
1571
  try {
1294
1572
  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(
1573
+ debug2("Scope resolution:");
1574
+ debug2(" - User config scopes: %O", this.config.scopes);
1575
+ debug2(
1298
1576
  " - Protected resource scopes_supported: %O",
1299
1577
  protectedResource.scopes_supported
1300
1578
  );
1301
- debug(
1579
+ debug2(
1302
1580
  " - Auth server scopes_supported: %O",
1303
1581
  authServer.server.scopes_supported
1304
1582
  );
1305
- debug(" - Final requested scopes: %O", requestedScopes);
1583
+ debug2(" - Final requested scopes: %O", requestedScopes);
1306
1584
  const authUrl = buildAuthorizationUrl({
1307
1585
  authServer,
1308
1586
  clientId: client.clientId,
@@ -1312,16 +1590,19 @@ ${errorText}`
1312
1590
  state,
1313
1591
  resource: protectedResource.resource
1314
1592
  });
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"));
1593
+ debug2(
1594
+ "Authorization URL (base): %s",
1595
+ `${authUrl.origin}${authUrl.pathname}`
1596
+ );
1597
+ debug2("Authorization URL params:");
1598
+ debug2(" - client_id: %s", authUrl.searchParams.get("client_id"));
1599
+ debug2(" - redirect_uri: %s", authUrl.searchParams.get("redirect_uri"));
1600
+ debug2(" - scope: %s", authUrl.searchParams.get("scope"));
1601
+ debug2(" - resource: %s", authUrl.searchParams.get("resource"));
1321
1602
  await this.openBrowserOrPrintUrl(authUrl);
1322
- debug("Waiting for OAuth callback...");
1603
+ debug2("Waiting for OAuth callback...");
1323
1604
  const code = await codePromise;
1324
- debug("Received authorization code");
1605
+ debug2("Received authorization code");
1325
1606
  const tokenResult = await exchangeCodeForTokens({
1326
1607
  authServer,
1327
1608
  clientId: client.clientId,
@@ -1359,14 +1640,14 @@ ${errorText}`
1359
1640
  let clientId;
1360
1641
  let clientSecret;
1361
1642
  if (storedTokens.clientId) {
1362
- debug("Using clientId from stored tokens for refresh");
1643
+ debug2("Using clientId from stored tokens for refresh");
1363
1644
  clientId = storedTokens.clientId;
1364
1645
  const storedClient = await this.storage.loadClient();
1365
1646
  if (storedClient?.clientId === clientId) {
1366
1647
  clientSecret = storedClient.clientSecret;
1367
1648
  }
1368
1649
  } else {
1369
- debug(
1650
+ debug2(
1370
1651
  "No clientId in stored tokens, falling back to stored client (legacy behavior)"
1371
1652
  );
1372
1653
  const client = await this.getOrRegisterClient(metadata.authServer);
@@ -1460,7 +1741,7 @@ ${errorText}`
1460
1741
  const preferredPort = this.config.callbackPort ?? 0;
1461
1742
  server.listen(preferredPort, "127.0.0.1", () => {
1462
1743
  const address = server.address();
1463
- debug("Callback server listening on port", address.port);
1744
+ debug2("Callback server listening on port", address.port);
1464
1745
  resolve3({ port: address.port, codePromise, close: forceClose });
1465
1746
  });
1466
1747
  server.on("error", (err) => {
@@ -1484,9 +1765,9 @@ ${errorText}`
1484
1765
  try {
1485
1766
  const open = await import('open');
1486
1767
  await open.default(url.toString());
1487
- debug("Opened browser for authentication");
1768
+ debug2("Opened browser for authentication");
1488
1769
  } catch (error) {
1489
- debug("Failed to open browser:", error);
1770
+ debug2("Failed to open browser:", error);
1490
1771
  console.log("\nFailed to open browser automatically.");
1491
1772
  console.log("Please open the following URL manually:\n");
1492
1773
  console.log(url.toString() + "\n");
@@ -1697,7 +1978,7 @@ function isCallToolResult(value) {
1697
1978
  return false;
1698
1979
  }
1699
1980
  const v = value;
1700
- return Array.isArray(v.content) || typeof v.isError === "boolean";
1981
+ return Array.isArray(v.content);
1701
1982
  }
1702
1983
  function extractTextFromContentArray(content) {
1703
1984
  const textParts = [];
@@ -2770,7 +3051,7 @@ async function token(serverUrl, options) {
2770
3051
 
2771
3052
  // src/cli/index.ts
2772
3053
  var program = new Command();
2773
- program.name("mcp-server-tester").description("CLI tools for MCP server evaluation and testing").version("0.1.0");
3054
+ program.name("mcp-server-tester").description("CLI tools for MCP server evaluation and testing").version(package_default.version);
2774
3055
  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
3056
  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
3057
  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(