headers.test.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import { test, expect, mock, beforeEach } from "bun:test"
  2. import { Effect } from "effect"
  3. import type { MCP as MCPNS } from "../../src/mcp/index"
  4. // Track what options were passed to each transport constructor
  5. const transportCalls: Array<{
  6. type: "streamable" | "sse"
  7. url: string
  8. options: { authProvider?: unknown; requestInit?: RequestInit }
  9. }> = []
  10. // Mock the transport constructors to capture their arguments
  11. mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
  12. StreamableHTTPClientTransport: class MockStreamableHTTP {
  13. constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
  14. transportCalls.push({
  15. type: "streamable",
  16. url: url.toString(),
  17. options: options ?? {},
  18. })
  19. }
  20. async start() {
  21. throw new Error("Mock transport cannot connect")
  22. }
  23. },
  24. }))
  25. mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
  26. SSEClientTransport: class MockSSE {
  27. constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) {
  28. transportCalls.push({
  29. type: "sse",
  30. url: url.toString(),
  31. options: options ?? {},
  32. })
  33. }
  34. async start() {
  35. throw new Error("Mock transport cannot connect")
  36. }
  37. },
  38. }))
  39. beforeEach(() => {
  40. transportCalls.length = 0
  41. })
  42. // Import MCP after mocking
  43. const { MCP } = await import("../../src/mcp/index")
  44. const { AppRuntime } = await import("../../src/effect/app-runtime")
  45. const { Instance } = await import("../../src/project/instance")
  46. const { tmpdir } = await import("../fixture/fixture")
  47. const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
  48. test("headers are passed to transports when oauth is enabled (default)", async () => {
  49. await using tmp = await tmpdir({
  50. init: async (dir) => {
  51. await Bun.write(
  52. `${dir}/opencode.json`,
  53. JSON.stringify({
  54. $schema: "https://opencode.ai/config.json",
  55. mcp: {
  56. "test-server": {
  57. type: "remote",
  58. url: "https://example.com/mcp",
  59. headers: {
  60. Authorization: "Bearer test-token",
  61. "X-Custom-Header": "custom-value",
  62. },
  63. },
  64. },
  65. }),
  66. )
  67. },
  68. })
  69. await Instance.provide({
  70. directory: tmp.path,
  71. fn: async () => {
  72. // Trigger MCP initialization - it will fail to connect but we can check the transport options
  73. await AppRuntime.runPromise(
  74. Effect.gen(function* () {
  75. const mcp = yield* service
  76. yield* mcp
  77. .add("test-server", {
  78. type: "remote",
  79. url: "https://example.com/mcp",
  80. headers: {
  81. Authorization: "Bearer test-token",
  82. "X-Custom-Header": "custom-value",
  83. },
  84. })
  85. .pipe(Effect.catch(() => Effect.void))
  86. }),
  87. )
  88. // Both transports should have been created with headers
  89. expect(transportCalls.length).toBeGreaterThanOrEqual(1)
  90. for (const call of transportCalls) {
  91. expect(call.options.requestInit).toBeDefined()
  92. expect(call.options.requestInit?.headers).toEqual({
  93. Authorization: "Bearer test-token",
  94. "X-Custom-Header": "custom-value",
  95. })
  96. // OAuth should be enabled by default, so authProvider should exist
  97. expect(call.options.authProvider).toBeDefined()
  98. }
  99. },
  100. })
  101. })
  102. test("headers are passed to transports when oauth is explicitly disabled", async () => {
  103. await using tmp = await tmpdir()
  104. await Instance.provide({
  105. directory: tmp.path,
  106. fn: async () => {
  107. transportCalls.length = 0
  108. await AppRuntime.runPromise(
  109. Effect.gen(function* () {
  110. const mcp = yield* service
  111. yield* mcp
  112. .add("test-server-no-oauth", {
  113. type: "remote",
  114. url: "https://example.com/mcp",
  115. oauth: false,
  116. headers: {
  117. Authorization: "Bearer test-token",
  118. },
  119. })
  120. .pipe(Effect.catch(() => Effect.void))
  121. }),
  122. )
  123. expect(transportCalls.length).toBeGreaterThanOrEqual(1)
  124. for (const call of transportCalls) {
  125. expect(call.options.requestInit).toBeDefined()
  126. expect(call.options.requestInit?.headers).toEqual({
  127. Authorization: "Bearer test-token",
  128. })
  129. // OAuth is disabled, so no authProvider
  130. expect(call.options.authProvider).toBeUndefined()
  131. }
  132. },
  133. })
  134. })
  135. test("no requestInit when headers are not provided", async () => {
  136. await using tmp = await tmpdir()
  137. await Instance.provide({
  138. directory: tmp.path,
  139. fn: async () => {
  140. transportCalls.length = 0
  141. await AppRuntime.runPromise(
  142. Effect.gen(function* () {
  143. const mcp = yield* service
  144. yield* mcp
  145. .add("test-server-no-headers", {
  146. type: "remote",
  147. url: "https://example.com/mcp",
  148. })
  149. .pipe(Effect.catch(() => Effect.void))
  150. }),
  151. )
  152. expect(transportCalls.length).toBeGreaterThanOrEqual(1)
  153. for (const call of transportCalls) {
  154. // No headers means requestInit should be undefined
  155. expect(call.options.requestInit).toBeUndefined()
  156. }
  157. },
  158. })
  159. })