Adapt the server integration test to the new standalone OAuth endpoints that store secrets without creating a source. Add workspace execution environment tests for GraphQL tool loading and invocation. Update auth service and control-plane runtime tests for the refactored APIs.
Unified · Split
| @@ -802,7 +802,7 @@ describe("local-executor-server", () => { · 801 unmodified lines | |||
| 802 | }), | 802 | }), |
| 803 | ); | 803 | ); |
| 804 | 804 | ||
| 805 | it.scoped("starts MCP OAuth from the sources API and completes the popup callback flow", () => | 805 | it.scoped("starts source OAuth without creating a source and stores secrets on callback", () => |
| 806 | Effect.gen(function* () { | 806 | Effect.gen(function* () { |
| 807 | const oauthServer = yield* Effect.acquireRelease( | 807 | const oauthServer = yield* Effect.acquireRelease( |
| 808 | Effect.promise(() => startOAuthProtectedMcpServer()), | 808 | Effect.promise(() => startOAuthProtectedMcpServer()), |
| @@ -820,16 +820,15 @@ describe("local-executor-server", () => { · 11 unmodified lines | |||
| 820 | const installation = yield* bootstrapClient.local.installation({}); | 820 | const installation = yield* bootstrapClient.local.installation({}); |
| 821 | 821 | ||
| 822 | const startResponse = yield* Effect.promise(() => | 822 | const startResponse = yield* Effect.promise(() => |
| 823 | fetch(`${server.baseUrl}/v1/workspaces/${encodeURIComponent(installation.workspaceId)}/sources/mcp/oauth/start`, { | 823 | fetch(`${server.baseUrl}/v1/workspaces/${encodeURIComponent(installation.workspaceId)}/oauth/source-auth/start`, { |
| 824 | method: "POST", | 824 | method: "POST", |
| 825 | headers: { | 825 | headers: { |
| 826 | "content-type": "application/json", | 826 | "content-type": "application/json", |
| 827 | "x-executor-account-id": installation.accountId, | 827 | "x-executor-account-id": installation.accountId, |
| 828 | }, | 828 | }, |
| 829 | body: JSON.stringify({ | 829 | body: JSON.stringify({ |
| 830 | provider: "mcp", | ||
| 830 | endpoint: oauthServer.endpoint, | 831 | endpoint: oauthServer.endpoint, |
| 831 | name: "Axiom", | ||
| 832 | namespace: "axiom", | ||
| 833 | transport: "auto", | 832 | transport: "auto", |
| 834 | }), | 833 | }), |
| 835 | }), | 834 | }), |
| @@ -837,29 +836,26 @@ describe("local-executor-server", () => { · 1 unmodified line | |||
| 837 | 836 | ||
| 838 | assertTrue(startResponse.ok); | 837 | assertTrue(startResponse.ok); |
| 839 | const started: { | 838 | const started: { |
| 840 | kind: "connected" | "oauth_required"; | 839 | sessionId: string; |
| 841 | source: { id: string; status: string; auth: { kind: string } }; | 840 | authorizationUrl: string; |
| 842 | authorizationUrl?: string; | ||
| 843 | } = yield* Effect.promise(() => startResponse.json() as Promise<{ | 841 | } = yield* Effect.promise(() => startResponse.json() as Promise<{ |
| 844 | kind: "connected" | "oauth_required"; | 842 | sessionId: string; |
| 845 | source: { id: string; status: string; auth: { kind: string } }; | 843 | authorizationUrl: string; |
| 846 | authorizationUrl?: string; | ||
| 847 | }>); | 844 | }>); |
| 848 | 845 | ||
| 849 | expect(started.kind).toBe("oauth_required"); | 846 | expect(started.sessionId).toBeTruthy(); |
| 850 | expect(started.source.status).toBe("auth_required"); | ||
| 851 | expect(started.authorizationUrl).toBeDefined(); | 847 | expect(started.authorizationUrl).toBeDefined(); |
| 852 | 848 | ||
| 853 | const callbackResponse = yield* Effect.promise(() => | 849 | const callbackResponse = yield* Effect.promise(() => |
| 854 | fetch(started.authorizationUrl!, { | 850 | fetch(started.authorizationUrl, { |
| 855 | redirect: "follow", | 851 | redirect: "follow", |
| 856 | }), | 852 | }), |
| 857 | ); | 853 | ); |
| 858 | 854 | ||
| 859 | assertTrue(callbackResponse.ok); | 855 | assertTrue(callbackResponse.ok); |
| 860 | const callbackHtml = yield* Effect.promise(() => callbackResponse.text()); | 856 | const callbackHtml = yield* Effect.promise(() => callbackResponse.text()); |
| 861 | expect(callbackHtml).toContain("Source connected:"); | 857 | expect(callbackHtml).toContain("OAuth connected"); |
| 862 | expect(callbackHtml).toContain("executor-v3:source-oauth-result"); | 858 | expect(callbackHtml).toContain("executor-v3:oauth-result"); |
| 863 | 859 | ||
| 864 | const client = yield* createControlPlaneClient({ | 860 | const client = yield* createControlPlaneClient({ |
| 865 | baseUrl: server.baseUrl, | 861 | baseUrl: server.baseUrl, |
| @@ -870,12 +866,12 @@ describe("local-executor-server", () => { · 4 unmodified lines | |||
| 870 | workspaceId: installation.workspaceId, | 866 | workspaceId: installation.workspaceId, |
| 871 | }, | 867 | }, |
| 872 | }); | 868 | }); |
| 873 | const source = sources.find((candidate) => candidate.id === started.source.id); | ||
| 874 | 869 | ||
| 875 | assertTrue(Boolean(source)); | 870 | expect(sources).toHaveLength(0); |
| 876 | 871 | ||
| 877 | expect(source?.status).toBe("connected"); | 872 | const secrets = yield* bootstrapClient.local.listSecrets({}); |
| 878 | expect(source?.auth.kind).toBe("oauth2"); | 873 | expect(secrets.some((secret) => secret.purpose === "oauth_access_token")).toBe(true); |
| 874 | expect(secrets.some((secret) => secret.purpose === "oauth_refresh_token")).toBe(true); | ||
| 879 | }), | 875 | }), |
| 880 | ); | 876 | ); |
| 881 | 877 | ||
| @@ -1,3 +1,5 @@ | |||
| 1 | import { createServer } from "node:http"; | ||
| 2 | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { assertTrue } from "@effect/vitest/utils"; | 4 | import { assertTrue } from "@effect/vitest/utils"; |
| 3 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| @@ -25,6 +27,80 @@ const makeRuntime = Effect.acquireRelease( · 21 unmodified lines | |||
| 25 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), | 27 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 26 | ); | 28 | ); |
| 27 | 29 | ||
| 30 | type OpenApiSpecServer = { | ||
| 31 | baseUrl: string; | ||
| 32 | specUrl: string; | ||
| 33 | close: () => Promise<void>; | ||
| 34 | }; | ||
| 35 | |||
| 36 | const makeOpenApiSpecServer = Effect.acquireRelease( | ||
| 37 | Effect.promise<OpenApiSpecServer>( | ||
| 38 | () => | ||
| 39 | new Promise<OpenApiSpecServer>((resolve, reject) => { | ||
| 40 | const openApiDocument = JSON.stringify({ | ||
| 41 | openapi: "3.0.3", | ||
| 42 | info: { | ||
| 43 | title: "GitHub Test API", | ||
| 44 | version: "1.0.0", | ||
| 45 | }, | ||
| 46 | paths: { | ||
| 47 | "/repos/{owner}/{repo}": { | ||
| 48 | get: { | ||
| 49 | operationId: "repos/get-repo", | ||
| 50 | tags: ["repos"], | ||
| 51 | summary: "Get a repository", | ||
| 52 | responses: { | ||
| 53 | 200: { | ||
| 54 | description: "ok", | ||
| 55 | }, | ||
| 56 | }, | ||
| 57 | }, | ||
| 58 | }, | ||
| 59 | }, | ||
| 60 | }); | ||
| 61 | |||
| 62 | const server = createServer((request, response) => { | ||
| 63 | if (request.url !== "/openapi.json") { | ||
| 64 | response.statusCode = 404; | ||
| 65 | response.end(); | ||
| 66 | return; | ||
| 67 | } | ||
| 68 | |||
| 69 | response.statusCode = 200; | ||
| 70 | response.setHeader("content-type", "application/json"); | ||
| 71 | response.end(openApiDocument); | ||
| 72 | }); | ||
| 73 | |||
| 74 | server.once("error", reject); | ||
| 75 | server.listen(0, "127.0.0.1", () => { | ||
| 76 | const address = server.address(); | ||
| 77 | if (!address || typeof address === "string") { | ||
| 78 | reject(new Error("Failed to bind OpenAPI runtime test server")); | ||
| 79 | return; | ||
| 80 | } | ||
| 81 | |||
| 82 | const baseUrl = `http://127.0.0.1:${address.port}`; | ||
| 83 | resolve({ | ||
| 84 | baseUrl, | ||
| 85 | specUrl: `${baseUrl}/openapi.json`, | ||
| 86 | close: () => | ||
| 87 | new Promise<void>((closeResolve, closeReject) => { | ||
| 88 | server.close((error) => { | ||
| 89 | if (error) { | ||
| 90 | closeReject(error); | ||
| 91 | return; | ||
| 92 | } | ||
| 93 | |||
| 94 | closeResolve(); | ||
| 95 | }); | ||
| 96 | }), | ||
| 97 | }); | ||
| 98 | }); | ||
| 99 | }), | ||
| 100 | ), | ||
| 101 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), | ||
| 102 | ); | ||
| 103 | |||
| 28 | const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) => | 104 | const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) => |
| 29 | Effect.either(effect).pipe( | 105 | Effect.either(effect).pipe( |
| 30 | Effect.flatMap((result) => | 106 | Effect.flatMap((result) => |
| @@ -38,6 +114,7 @@ describe("control-plane-runtime", () => { · 7 unmodified lines | |||
| 38 | it.scoped("supports full CRUD flow over HTTP API", () => | 114 | it.scoped("supports full CRUD flow over HTTP API", () => |
| 39 | Effect.gen(function* () { | 115 | Effect.gen(function* () { |
| 40 | const runtime = yield* makeRuntime; | 116 | const runtime = yield* makeRuntime; |
| 117 | const openApiServer = yield* makeOpenApiSpecServer; | ||
| 41 | 118 | ||
| 42 | const createOrg = yield* withControlPlaneClient( | 119 | const createOrg = yield* withControlPlaneClient( |
| 43 | { runtime, accountId: "acc_1" }, | 120 | { runtime, accountId: "acc_1" }, |
| @@ -69,8 +146,8 @@ describe("control-plane-runtime", () => { · 25 unmodified lines | |||
| 69 | payload: { | 146 | payload: { |
| 70 | name: "Github", | 147 | name: "Github", |
| 71 | kind: "openapi", | 148 | kind: "openapi", |
| 72 | endpoint: "https://api.github.com", | 149 | endpoint: openApiServer.baseUrl, |
| 73 | specUrl: "https://api.github.com/openapi.json", | 150 | specUrl: openApiServer.specUrl, |
| 74 | auth: { | 151 | auth: { |
| 75 | kind: "none", | 152 | kind: "none", |
| 76 | }, | 153 | }, |
| @@ -115,6 +192,62 @@ describe("control-plane-runtime", () => { · 38 unmodified lines | |||
| 115 | }), | 192 | }), |
| 116 | ); | 193 | ); |
| 117 | 194 | ||
| 195 | it.scoped("discovers and connects an OpenAPI source through the HTTP API", () => | ||
| 196 | Effect.gen(function* () { | ||
| 197 | const runtime = yield* makeRuntime; | ||
| 198 | const openApiServer = yield* makeOpenApiSpecServer; | ||
| 199 | |||
| 200 | const organization = yield* withControlPlaneClient( | ||
| 201 | { runtime, accountId: "acc_discover" }, | ||
| 202 | (client) => | ||
| 203 | client.organizations.create({ | ||
| 204 | payload: { name: "Discovery Org" }, | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | const workspace = yield* withControlPlaneClient( | ||
| 208 | { runtime, accountId: "acc_discover" }, | ||
| 209 | (client) => | ||
| 210 | client.workspaces.create({ | ||
| 211 | path: { organizationId: organization.id }, | ||
| 212 | payload: { name: "Discovery Workspace" }, | ||
| 213 | }), | ||
| 214 | ); | ||
| 215 | |||
| 216 | const discovered = yield* withControlPlaneClient( | ||
| 217 | { runtime, accountId: "acc_discover" }, | ||
| 218 | (client) => | ||
| 219 | client.sources.discover({ | ||
| 220 | payload: { | ||
| 221 | url: openApiServer.specUrl, | ||
| 222 | }, | ||
| 223 | }), | ||
| 224 | ); | ||
| 225 | expect(discovered.detectedKind).toBe("openapi"); | ||
| 226 | expect(discovered.specUrl).toBe(openApiServer.specUrl); | ||
| 227 | expect(discovered.endpoint).toBe(openApiServer.baseUrl); | ||
| 228 | |||
| 229 | const connected = yield* withControlPlaneClient( | ||
| 230 | { runtime, accountId: "acc_discover" }, | ||
| 231 | (client) => | ||
| 232 | client.sources.connect({ | ||
| 233 | path: { workspaceId: workspace.id }, | ||
| 234 | payload: { | ||
| 235 | kind: "openapi", | ||
| 236 | endpoint: discovered.endpoint, | ||
| 237 | specUrl: discovered.specUrl ?? openApiServer.specUrl, | ||
| 238 | name: discovered.name, | ||
| 239 | namespace: discovered.namespace, | ||
| 240 | auth: { kind: "none" }, | ||
| 241 | }, | ||
| 242 | }), | ||
| 243 | ); | ||
| 244 | expect(connected.kind).toBe("connected"); | ||
| 245 | expect(connected.source.kind).toBe("openapi"); | ||
| 246 | expect(connected.source.status).toBe("connected"); | ||
| 247 | expect(connected.source.specUrl).toBe(openApiServer.specUrl); | ||
| 248 | }), | ||
| 249 | ); | ||
| 250 | |||
| 118 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => | 251 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => |
| 119 | Effect.gen(function* () { | 252 | Effect.gen(function* () { |
| 120 | const runtime = yield* makeRuntime; | 253 | const runtime = yield* makeRuntime; |
| @@ -4,7 +4,7 @@ import type { Source } from "#schema"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | createTerminalSourceAuthSessionPatch, | 6 | createTerminalSourceAuthSessionPatch, |
| 7 | shouldPromptForOpenApiCredentialSetup, | 7 | shouldPromptForHttpCredentialSetup, |
| 8 | } from "./source-auth-service"; | 8 | } from "./source-auth-service"; |
| 9 | 9 | ||
| 10 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ | 10 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ |
| @@ -81,9 +81,9 @@ describe("source-auth-service", () => { · 70 unmodified lines | |||
| 81 | }); | 81 | }); |
| 82 | }); | 82 | }); |
| 83 | 83 | ||
| 84 | it("reuses existing non-none OpenAPI auth without prompting again", () => { | 84 | it("reuses existing non-none HTTP source auth without prompting again", () => { |
| 85 | expect( | 85 | expect( |
| 86 | shouldPromptForOpenApiCredentialSetup({ | 86 | shouldPromptForHttpCredentialSetup({ |
| 87 | existing: makeExistingOpenApiSource({ | 87 | existing: makeExistingOpenApiSource({ |
| 88 | kind: "bearer", | 88 | kind: "bearer", |
| 89 | headerName: "Authorization", | 89 | headerName: "Authorization", |
| @@ -97,7 +97,7 @@ describe("source-auth-service", () => { · 7 unmodified lines | |||
| 97 | ).toBe(false); | 97 | ).toBe(false); |
| 98 | 98 | ||
| 99 | expect( | 99 | expect( |
| 100 | shouldPromptForOpenApiCredentialSetup({ | 100 | shouldPromptForHttpCredentialSetup({ |
| 101 | existing: makeExistingOpenApiSource({ | 101 | existing: makeExistingOpenApiSource({ |
| 102 | kind: "oauth2", | 102 | kind: "oauth2", |
| 103 | headerName: "Authorization", | 103 | headerName: "Authorization", |
| @@ -115,11 +115,29 @@ describe("source-auth-service", () => { · 11 unmodified lines | |||
| 115 | ).toBe(false); | 115 | ).toBe(false); |
| 116 | 116 | ||
| 117 | expect( | 117 | expect( |
| 118 | shouldPromptForOpenApiCredentialSetup({ | 118 | shouldPromptForHttpCredentialSetup({ |
| 119 | existing: makeExistingOpenApiSource({ | 119 | existing: makeExistingOpenApiSource({ |
| 120 | kind: "none", | 120 | kind: "none", |
| 121 | }), | 121 | }), |
| 122 | }), | 122 | }), |
| 123 | ).toBe(true); | 123 | ).toBe(true); |
| 124 | |||
| 125 | expect( | ||
| 126 | shouldPromptForHttpCredentialSetup({ | ||
| 127 | existing: { | ||
| 128 | ...makeExistingOpenApiSource({ | ||
| 129 | kind: "bearer", | ||
| 130 | headerName: "Authorization", | ||
| 131 | prefix: "Bearer ", | ||
| 132 | token: { | ||
| 133 | providerId: "postgres", | ||
| 134 | handle: "sec_graphql", | ||
| 135 | }, | ||
| 136 | }), | ||
| 137 | kind: "graphql", | ||
| 138 | specUrl: null, | ||
| 139 | }, | ||
| 140 | }), | ||
| 141 | ).toBe(false); | ||
| 124 | }); | 142 | }); |
| 125 | }); | 143 | }); |
| @@ -0,0 +1,151 @@ | |||
| 1 | import { | ||
| 2 | createServer, | ||
| 3 | type IncomingMessage, | ||
| 4 | type ServerResponse, | ||
| 5 | } from "node:http"; | ||
| 6 | |||
| 7 | import { describe, expect, it } from "vitest"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | |||
| 10 | import { discoverSource } from "./source-discovery"; | ||
| 11 | |||
| 12 | type TestServer = { | ||
| 13 | url: string; | ||
| 14 | close: () => Promise<void>; | ||
| 15 | }; | ||
| 16 | |||
| 17 | const withServer = async ( | ||
| 18 | handler: (request: IncomingMessage, response: ServerResponse<IncomingMessage>) => void, | ||
| 19 | ): Promise<TestServer> => { | ||
| 20 | const server = createServer(handler); | ||
| 21 | |||
| 22 | await new Promise<void>((resolve, reject) => { | ||
| 23 | server.once("error", reject); | ||
| 24 | server.listen(0, "127.0.0.1", () => resolve()); | ||
| 25 | }); | ||
| 26 | |||
| 27 | const address = server.address(); | ||
| 28 | if (!address || typeof address === "string") { | ||
| 29 | throw new Error("Failed to resolve test server address"); | ||
| 30 | } | ||
| 31 | |||
| 32 | return { | ||
| 33 | url: `http://127.0.0.1:${address.port}`, | ||
| 34 | close: () => | ||
| 35 | new Promise<void>((resolve, reject) => { | ||
| 36 | server.close((error) => { | ||
| 37 | if (error) { | ||
| 38 | reject(error); | ||
| 39 | return; | ||
| 40 | } | ||
| 41 | resolve(); | ||
| 42 | }); | ||
| 43 | }), | ||
| 44 | }; | ||
| 45 | }; | ||
| 46 | |||
| 47 | describe("source-discovery", () => { | ||
| 48 | it("detects OpenAPI and infers bearer auth from security schemes", async () => { | ||
| 49 | const server = await withServer((request, response) => { | ||
| 50 | if (request.url !== "/openapi.json") { | ||
| 51 | response.statusCode = 404; | ||
| 52 | response.end(); | ||
| 53 | return; | ||
| 54 | } | ||
| 55 | |||
| 56 | if (request.headers.authorization !== "Bearer top-secret") { | ||
| 57 | response.statusCode = 401; | ||
| 58 | response.setHeader("www-authenticate", 'Bearer realm="spec"'); | ||
| 59 | response.end("Unauthorized"); | ||
| 60 | return; | ||
| 61 | } | ||
| 62 | |||
| 63 | response.statusCode = 200; | ||
| 64 | response.setHeader("content-type", "application/json"); | ||
| 65 | response.end(JSON.stringify({ | ||
| 66 | openapi: "3.0.3", | ||
| 67 | info: { | ||
| 68 | title: "Secure Example API", | ||
| 69 | version: "1.0.0", | ||
| 70 | }, | ||
| 71 | servers: [{ url: `${server.url}/api` }], | ||
| 72 | components: { | ||
| 73 | securitySchemes: { | ||
| 74 | bearerAuth: { | ||
| 75 | type: "http", | ||
| 76 | scheme: "bearer", | ||
| 77 | }, | ||
| 78 | }, | ||
| 79 | }, | ||
| 80 | security: [{ bearerAuth: [] }], | ||
| 81 | paths: { | ||
| 82 | "/widgets": { | ||
| 83 | get: { | ||
| 84 | operationId: "widgets/list", | ||
| 85 | responses: { | ||
| 86 | 200: { | ||
| 87 | description: "ok", | ||
| 88 | }, | ||
| 89 | }, | ||
| 90 | }, | ||
| 91 | }, | ||
| 92 | }, | ||
| 93 | })); | ||
| 94 | }); | ||
| 95 | |||
| 96 | try { | ||
| 97 | const result = await Effect.runPromise(discoverSource({ | ||
| 98 | url: `${server.url}/openapi.json`, | ||
| 99 | probeAuth: { | ||
| 100 | kind: "bearer", | ||
| 101 | token: "top-secret", | ||
| 102 | }, | ||
| 103 | })); | ||
| 104 | |||
| 105 | expect(result.detectedKind).toBe("openapi"); | ||
| 106 | expect(result.authInference.suggestedKind).toBe("bearer"); | ||
| 107 | expect(result.authInference.supported).toBe(true); | ||
| 108 | expect(result.authInference.headerName).toBe("Authorization"); | ||
| 109 | expect(result.specUrl).toBe(`${server.url}/openapi.json`); | ||
| 110 | expect(result.endpoint).toBe(`${server.url}/api`); | ||
| 111 | expect(result.toolCount).toBe(1); | ||
| 112 | } finally { | ||
| 113 | await server.close(); | ||
| 114 | } | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("detects GraphQL from successful introspection", async () => { | ||
| 118 | const server = await withServer((request, response) => { | ||
| 119 | if (request.url !== "/graphql" || request.method !== "POST") { | ||
| 120 | response.statusCode = 404; | ||
| 121 | response.end(); | ||
| 122 | return; | ||
| 123 | } | ||
| 124 | |||
| 125 | response.statusCode = 200; | ||
| 126 | response.setHeader("content-type", "application/json"); | ||
| 127 | response.end(JSON.stringify({ | ||
| 128 | data: { | ||
| 129 | __schema: { | ||
| 130 | queryType: { | ||
| 131 | name: "Query", | ||
| 132 | }, | ||
| 133 | }, | ||
| 134 | }, | ||
| 135 | })); | ||
| 136 | }); | ||
| 137 | |||
| 138 | try { | ||
| 139 | const result = await Effect.runPromise(discoverSource({ | ||
| 140 | url: `${server.url}/graphql`, | ||
| 141 | })); | ||
| 142 | |||
| 143 | expect(result.detectedKind).toBe("graphql"); | ||
| 144 | expect(result.confidence).toBe("high"); | ||
| 145 | expect(result.authInference.suggestedKind).toBe("none"); | ||
| 146 | expect(result.specUrl).toBeNull(); | ||
| 147 | } finally { | ||
| 148 | await server.close(); | ||
| 149 | } | ||
| 150 | }); | ||
| 151 | }); | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { createServer } from "node:http"; | ||
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 4 | HttpApi, | 5 | HttpApi, |
| @@ -13,6 +14,18 @@ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js · 8 unmodified lines | |||
| 13 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 14 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 14 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | 15 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 15 | import { describe, expect, it } from "@effect/vitest"; | 16 | import { describe, expect, it } from "@effect/vitest"; |
| 17 | import { | ||
| 18 | graphql, | ||
| 19 | GraphQLBoolean, | ||
| 20 | GraphQLID, | ||
| 21 | GraphQLInputObjectType, | ||
| 22 | GraphQLInt, | ||
| 23 | GraphQLList, | ||
| 24 | GraphQLNonNull, | ||
| 25 | GraphQLObjectType, | ||
| 26 | GraphQLSchema, | ||
| 27 | GraphQLString, | ||
| 28 | } from "graphql"; | ||
| 16 | import { | 29 | import { |
| 17 | AccountIdSchema, | 30 | AccountIdSchema, |
| 18 | CredentialIdSchema, | 31 | CredentialIdSchema, |
| @@ -59,6 +72,202 @@ type OpenApiSpecServer = { · 40 unmodified lines | |||
| 59 | close: () => Promise<void>; | 72 | close: () => Promise<void>; |
| 60 | }; | 73 | }; |
| 61 | 74 | ||
| 75 | type GraphqlServer = { | ||
| 76 | endpoint: string; | ||
| 77 | seenAuthHeaders: Array<string | null>; | ||
| 78 | close: () => Promise<void>; | ||
| 79 | }; | ||
| 80 | |||
| 81 | const GraphqlUserType = new GraphQLObjectType({ | ||
| 82 | name: "User", | ||
| 83 | fields: { | ||
| 84 | id: { | ||
| 85 | type: new GraphQLNonNull(GraphQLID), | ||
| 86 | }, | ||
| 87 | name: { | ||
| 88 | type: new GraphQLNonNull(GraphQLString), | ||
| 89 | }, | ||
| 90 | }, | ||
| 91 | }); | ||
| 92 | |||
| 93 | const GraphqlTeamType = new GraphQLObjectType({ | ||
| 94 | name: "Team", | ||
| 95 | fields: { | ||
| 96 | id: { | ||
| 97 | type: new GraphQLNonNull(GraphQLID), | ||
| 98 | }, | ||
| 99 | key: { | ||
| 100 | type: new GraphQLNonNull(GraphQLString), | ||
| 101 | }, | ||
| 102 | name: { | ||
| 103 | type: new GraphQLNonNull(GraphQLString), | ||
| 104 | }, | ||
| 105 | }, | ||
| 106 | }); | ||
| 107 | |||
| 108 | const GraphqlIssueType = new GraphQLObjectType({ | ||
| 109 | name: "Issue", | ||
| 110 | fields: { | ||
| 111 | id: { | ||
| 112 | type: new GraphQLNonNull(GraphQLID), | ||
| 113 | }, | ||
| 114 | identifier: { | ||
| 115 | type: new GraphQLNonNull(GraphQLString), | ||
| 116 | }, | ||
| 117 | title: { | ||
| 118 | type: new GraphQLNonNull(GraphQLString), | ||
| 119 | }, | ||
| 120 | team: { | ||
| 121 | type: new GraphQLNonNull(GraphqlTeamType), | ||
| 122 | }, | ||
| 123 | }, | ||
| 124 | }); | ||
| 125 | |||
| 126 | const GraphqlPageInfoType = new GraphQLObjectType({ | ||
| 127 | name: "PageInfo", | ||
| 128 | fields: { | ||
| 129 | hasNextPage: { | ||
| 130 | type: new GraphQLNonNull(GraphQLBoolean), | ||
| 131 | }, | ||
| 132 | endCursor: { | ||
| 133 | type: GraphQLString, | ||
| 134 | }, | ||
| 135 | }, | ||
| 136 | }); | ||
| 137 | |||
| 138 | const GraphqlIssueConnectionType = new GraphQLObjectType({ | ||
| 139 | name: "IssueConnection", | ||
| 140 | fields: { | ||
| 141 | nodes: { | ||
| 142 | type: new GraphQLNonNull( | ||
| 143 | new GraphQLList(new GraphQLNonNull(GraphqlIssueType)), | ||
| 144 | ), | ||
| 145 | }, | ||
| 146 | pageInfo: { | ||
| 147 | type: new GraphQLNonNull(GraphqlPageInfoType), | ||
| 148 | }, | ||
| 149 | }, | ||
| 150 | }); | ||
| 151 | |||
| 152 | const GraphqlCreateIssueInputType = new GraphQLInputObjectType({ | ||
| 153 | name: "CreateIssueInput", | ||
| 154 | fields: { | ||
| 155 | title: { | ||
| 156 | type: new GraphQLNonNull(GraphQLString), | ||
| 157 | }, | ||
| 158 | teamId: { | ||
| 159 | type: GraphQLID, | ||
| 160 | }, | ||
| 161 | }, | ||
| 162 | }); | ||
| 163 | |||
| 164 | const GraphqlQueryType = new GraphQLObjectType({ | ||
| 165 | name: "Query", | ||
| 166 | fields: { | ||
| 167 | viewer: { | ||
| 168 | type: new GraphQLNonNull(GraphqlUserType), | ||
| 169 | description: "The current user", | ||
| 170 | resolve: () => ({ | ||
| 171 | id: "usr_123", | ||
| 172 | name: "Rhys", | ||
| 173 | }), | ||
| 174 | }, | ||
| 175 | issue: { | ||
| 176 | type: GraphqlIssueType, | ||
| 177 | description: "Find a single issue by id", | ||
| 178 | args: { | ||
| 179 | id: { | ||
| 180 | type: new GraphQLNonNull(GraphQLID), | ||
| 181 | }, | ||
| 182 | }, | ||
| 183 | resolve: (_source, args: { id: string }) => ({ | ||
| 184 | id: args.id, | ||
| 185 | identifier: "ENG-123", | ||
| 186 | title: "Investigate GraphQL support", | ||
| 187 | team: { | ||
| 188 | id: "team_eng", | ||
| 189 | key: "ENG", | ||
| 190 | name: "Engineering", | ||
| 191 | }, | ||
| 192 | }), | ||
| 193 | }, | ||
| 194 | issues: { | ||
| 195 | type: new GraphQLNonNull(GraphqlIssueConnectionType), | ||
| 196 | description: "List issues for the workspace", | ||
| 197 | args: { | ||
| 198 | first: { | ||
| 199 | type: GraphQLInt, | ||
| 200 | }, | ||
| 201 | query: { | ||
| 202 | type: GraphQLString, | ||
| 203 | }, | ||
| 204 | }, | ||
| 205 | resolve: () => ({ | ||
| 206 | nodes: [ | ||
| 207 | { | ||
| 208 | id: "iss_1", | ||
| 209 | identifier: "ENG-123", | ||
| 210 | title: "Investigate GraphQL support", | ||
| 211 | team: { | ||
| 212 | id: "team_eng", | ||
| 213 | key: "ENG", | ||
| 214 | name: "Engineering", | ||
| 215 | }, | ||
| 216 | }, | ||
| 217 | { | ||
| 218 | id: "iss_2", | ||
| 219 | identifier: "ENG-124", | ||
| 220 | title: "Ship GraphQL field tools", | ||
| 221 | team: { | ||
| 222 | id: "team_eng", | ||
| 223 | key: "ENG", | ||
| 224 | name: "Engineering", | ||
| 225 | }, | ||
| 226 | }, | ||
| 227 | ], | ||
| 228 | pageInfo: { | ||
| 229 | hasNextPage: false, | ||
| 230 | endCursor: "iss_2", | ||
| 231 | }, | ||
| 232 | }), | ||
| 233 | }, | ||
| 234 | }, | ||
| 235 | }); | ||
| 236 | |||
| 237 | const GraphqlMutationType = new GraphQLObjectType({ | ||
| 238 | name: "Mutation", | ||
| 239 | fields: { | ||
| 240 | noop: { | ||
| 241 | type: GraphQLString, | ||
| 242 | resolve: () => "ok", | ||
| 243 | }, | ||
| 244 | createIssue: { | ||
| 245 | type: new GraphQLNonNull(GraphqlIssueType), | ||
| 246 | description: "Create a new issue", | ||
| 247 | args: { | ||
| 248 | input: { | ||
| 249 | type: new GraphQLNonNull(GraphqlCreateIssueInputType), | ||
| 250 | }, | ||
| 251 | }, | ||
| 252 | resolve: (_source, args: { input: { title: string; teamId?: string | null } }) => ({ | ||
| 253 | id: "iss_created", | ||
| 254 | identifier: "ENG-999", | ||
| 255 | title: args.input.title, | ||
| 256 | team: { | ||
| 257 | id: args.input.teamId ?? "team_eng", | ||
| 258 | key: "ENG", | ||
| 259 | name: "Engineering", | ||
| 260 | }, | ||
| 261 | }), | ||
| 262 | }, | ||
| 263 | }, | ||
| 264 | }); | ||
| 265 | |||
| 266 | const testGraphqlSchema = new GraphQLSchema({ | ||
| 267 | query: GraphqlQueryType, | ||
| 268 | mutation: GraphqlMutationType, | ||
| 269 | }); | ||
| 270 | |||
| 62 | const closeScope = (scope: Scope.CloseableScope) => | 271 | const closeScope = (scope: Scope.CloseableScope) => |
| 63 | Scope.close(scope, Exit.void).pipe(Effect.orDie); | 272 | Scope.close(scope, Exit.void).pipe(Effect.orDie); |
| 64 | 273 | ||
| @@ -340,6 +549,82 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 275 unmodified lines | |||
| 340 | close: () => Effect.runPromise(closeScope(scope)), | 549 | close: () => Effect.runPromise(closeScope(scope)), |
| 341 | } satisfies OpenApiSpecServer; | 550 | } satisfies OpenApiSpecServer; |
| 342 | }), | 551 | }), |
| 552 | (server) => | ||
| 553 | Effect.tryPromise({ | ||
| 554 | try: () => server.close(), | ||
| 555 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | ||
| 556 | }).pipe(Effect.orDie), | ||
| 557 | ); | ||
| 558 | |||
| 559 | const makeGraphqlServer = Effect.acquireRelease( | ||
| 560 | Effect.promise<GraphqlServer>( | ||
| 561 | () => | ||
| 562 | new Promise<GraphqlServer>((resolve, reject) => { | ||
| 563 | const seenAuthHeaders: Array<string | null> = []; | ||
| 564 | const server = createServer(async (request, response) => { | ||
| 565 | if (request.method !== "POST") { | ||
| 566 | response.statusCode = 405; | ||
| 567 | response.end(); | ||
| 568 | return; | ||
| 569 | } | ||
| 570 | |||
| 571 | const chunks: Array<Buffer> = []; | ||
| 572 | for await (const chunk of request) { | ||
| 573 | chunks.push(Buffer.from(chunk)); | ||
| 574 | } | ||
| 575 | |||
| 576 | const rawBody = Buffer.concat(chunks).toString("utf8"); | ||
| 577 | const parsedBody = JSON.parse(rawBody) as Record<string, unknown>; | ||
| 578 | seenAuthHeaders.push( | ||
| 579 | typeof request.headers.authorization === "string" | ||
| 580 | ? request.headers.authorization | ||
| 581 | : null, | ||
| 582 | ); | ||
| 583 | response.statusCode = 200; | ||
| 584 | response.setHeader("content-type", "application/json"); | ||
| 585 | |||
| 586 | const result = await graphql({ | ||
| 587 | schema: testGraphqlSchema, | ||
| 588 | source: typeof parsedBody.query === "string" ? parsedBody.query : "", | ||
| 589 | variableValues: | ||
| 590 | parsedBody.variables && typeof parsedBody.variables === "object" | ||
| 591 | ? (parsedBody.variables as Record<string, unknown>) | ||
| 592 | : undefined, | ||
| 593 | operationName: | ||
| 594 | typeof parsedBody.operationName === "string" | ||
| 595 | ? parsedBody.operationName | ||
| 596 | : undefined, | ||
| 597 | }); | ||
| 598 | |||
| 599 | response.end(JSON.stringify(result)); | ||
| 600 | }); | ||
| 601 | |||
| 602 | server.once("error", reject); | ||
| 603 | server.listen(0, "127.0.0.1", () => { | ||
| 604 | const address = server.address(); | ||
| 605 | if (!address || typeof address === "string") { | ||
| 606 | reject(new Error("Failed to resolve GraphQL test server address")); | ||
| 607 | return; | ||
| 608 | } | ||
| 609 | |||
| 610 | resolve({ | ||
| 611 | endpoint: `http://127.0.0.1:${address.port}/graphql`, | ||
| 612 | seenAuthHeaders, | ||
| 613 | close: () => | ||
| 614 | new Promise<void>((closeResolve, closeReject) => { | ||
| 615 | server.close((error) => { | ||
| 616 | if (error) { | ||
| 617 | closeReject(error); | ||
| 618 | return; | ||
| 619 | } | ||
| 620 | |||
| 621 | closeResolve(); | ||
| 622 | }); | ||
| 623 | }), | ||
| 624 | }); | ||
| 625 | }); | ||
| 626 | }), | ||
| 627 | ), | ||
| 343 | (server) => | 628 | (server) => |
| 344 | Effect.tryPromise({ | 629 | Effect.tryPromise({ |
| 345 | try: () => server.close(), | 630 | try: () => server.close(), |
| @@ -585,6 +870,8 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 239 unmodified lines | |||
| 585 | getSourceById: () => Effect.fail(new Error("not implemented in test")), | 870 | getSourceById: () => Effect.fail(new Error("not implemented in test")), |
| 586 | addExecutorSource: () => Effect.fail(new Error("not implemented in test")), | 871 | addExecutorSource: () => Effect.fail(new Error("not implemented in test")), |
| 587 | connectMcpSource: () => Effect.fail(new Error("not implemented in test")), | 872 | connectMcpSource: () => Effect.fail(new Error("not implemented in test")), |
| 873 | startSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")), | ||
| 874 | completeSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")), | ||
| 588 | completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")), | 875 | completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")), |
| 589 | } as RuntimeSourceAuthService, | 876 | } as RuntimeSourceAuthService, |
| 590 | }); | 877 | }); |
| @@ -865,6 +1152,196 @@ describe("workspace-execution-environment", () => { · 274 unmodified lines | |||
| 865 | }), | 1152 | }), |
| 866 | ); | 1153 | ); |
| 867 | 1154 | ||
| 1155 | it.scoped("adds a GraphQL source through executor.sources.add with elicited credentials", () => | ||
| 1156 | Effect.gen(function* () { | ||
| 1157 | const graphqlServer = yield* makeGraphqlServer; | ||
| 1158 | const persistence = yield* makePersistence; | ||
| 1159 | const workspaceId = WorkspaceIdSchema.make("ws_add_graphql"); | ||
| 1160 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | ||
| 1161 | const now = Date.now(); | ||
| 1162 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( | ||
| 1163 | "sec_test_graphql_bearer", | ||
| 1164 | ); | ||
| 1165 | yield* persistence.rows.secretMaterials.upsert({ | ||
| 1166 | id: tokenSecretMaterialId, | ||
| 1167 | name: null, | ||
| 1168 | purpose: "auth_material", | ||
| 1169 | value: "linear_test_token", | ||
| 1170 | createdAt: now, | ||
| 1171 | updatedAt: now, | ||
| 1172 | }); | ||
| 1173 | |||
| 1174 | const environment = yield* resolveEnvironment({ | ||
| 1175 | workspaceId, | ||
| 1176 | accountId: AccountIdSchema.make("acc_add_graphql"), | ||
| 1177 | executionId: ExecutionIdSchema.make("exec_add_graphql"), | ||
| 1178 | onElicitation: () => | ||
| 1179 | Effect.succeed({ | ||
| 1180 | action: "accept" as const, | ||
| 1181 | content: { | ||
| 1182 | authKind: "bearer", | ||
| 1183 | tokenRef: { | ||
| 1184 | providerId: "postgres", | ||
| 1185 | handle: tokenSecretMaterialId, | ||
| 1186 | }, | ||
| 1187 | }, | ||
| 1188 | }), | ||
| 1189 | }); | ||
| 1190 | |||
| 1191 | const added = (yield* environment.toolInvoker.invoke({ | ||
| 1192 | path: "executor.sources.add", | ||
| 1193 | args: { | ||
| 1194 | kind: "graphql", | ||
| 1195 | endpoint: graphqlServer.endpoint, | ||
| 1196 | name: "Linear", | ||
| 1197 | namespace: "linear", | ||
| 1198 | }, | ||
| 1199 | context: { | ||
| 1200 | runId: "exec_add_graphql", | ||
| 1201 | }, | ||
| 1202 | })) as { | ||
| 1203 | id: SourceId; | ||
| 1204 | kind: string; | ||
| 1205 | status: string; | ||
| 1206 | auth: { | ||
| 1207 | kind: string; | ||
| 1208 | token?: { | ||
| 1209 | providerId: string; | ||
| 1210 | }; | ||
| 1211 | }; | ||
| 1212 | }; | ||
| 1213 | |||
| 1214 | expect(added.kind).toBe("graphql"); | ||
| 1215 | expect(added.status).toBe("connected"); | ||
| 1216 | expect(added.auth.kind).toBe("bearer"); | ||
| 1217 | expect(added.auth.token?.providerId).toBe("postgres"); | ||
| 1218 | |||
| 1219 | const storedSource = yield* persistence.rows.sources.getByWorkspaceAndId( | ||
| 1220 | workspaceId, | ||
| 1221 | added.id, | ||
| 1222 | ); | ||
| 1223 | expect(Option.isSome(storedSource)).toBe(true); | ||
| 1224 | if (Option.isSome(storedSource)) { | ||
| 1225 | expect(storedSource.value.sourceDocumentText).toContain('"__schema"'); | ||
| 1226 | } | ||
| 1227 | |||
| 1228 | const freshEnvironment = yield* resolveEnvironment({ | ||
| 1229 | workspaceId, | ||
| 1230 | accountId: AccountIdSchema.make("acc_add_graphql_fresh"), | ||
| 1231 | executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"), | ||
| 1232 | }); | ||
| 1233 | |||
| 1234 | const discovered = (yield* freshEnvironment.toolInvoker.invoke({ | ||
| 1235 | path: "discover", | ||
| 1236 | args: { | ||
| 1237 | query: "linear current user viewer", | ||
| 1238 | limit: 5, | ||
| 1239 | }, | ||
| 1240 | })) as { | ||
| 1241 | bestPath: string | null; | ||
| 1242 | }; | ||
| 1243 | |||
| 1244 | expect(discovered.bestPath).toBe("linear.viewer"); | ||
| 1245 | |||
| 1246 | const describeCreateIssue = (yield* freshEnvironment.toolInvoker.invoke({ | ||
| 1247 | path: "describe.tool", | ||
| 1248 | args: { | ||
| 1249 | path: "linear.createIssue", | ||
| 1250 | includeSchemas: true, | ||
| 1251 | }, | ||
| 1252 | })) as { | ||
| 1253 | path: string; | ||
| 1254 | inputType?: string; | ||
| 1255 | inputSchemaJson?: string; | ||
| 1256 | } | null; | ||
| 1257 | |||
| 1258 | expect(describeCreateIssue?.path).toBe("linear.createIssue"); | ||
| 1259 | expect(describeCreateIssue?.inputType).toContain("input"); | ||
| 1260 | expect(describeCreateIssue?.inputType).toContain("title"); | ||
| 1261 | |||
| 1262 | const viewerResult = (yield* freshEnvironment.toolInvoker.invoke({ | ||
| 1263 | path: "linear.viewer", | ||
| 1264 | args: {}, | ||
| 1265 | })) as { | ||
| 1266 | data: { | ||
| 1267 | id: string; | ||
| 1268 | name: string; | ||
| 1269 | __typename: string; | ||
| 1270 | }; | ||
| 1271 | errors: unknown[]; | ||
| 1272 | }; | ||
| 1273 | |||
| 1274 | expect(viewerResult).toMatchObject({ | ||
| 1275 | data: { | ||
| 1276 | id: "usr_123", | ||
| 1277 | name: "Rhys", | ||
| 1278 | __typename: "User", | ||
| 1279 | }, | ||
| 1280 | errors: [], | ||
| 1281 | }); | ||
| 1282 | |||
| 1283 | const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({ | ||
| 1284 | path: "linear.createIssue", | ||
| 1285 | args: { | ||
| 1286 | input: { | ||
| 1287 | title: "Ship GraphQL field tools", | ||
| 1288 | }, | ||
| 1289 | }, | ||
| 1290 | })) as { | ||
| 1291 | data: { | ||
| 1292 | id: string; | ||
| 1293 | identifier: string; | ||
| 1294 | title: string; | ||
| 1295 | __typename: string; | ||
| 1296 | }; | ||
| 1297 | errors: unknown[]; | ||
| 1298 | }; | ||
| 1299 | |||
| 1300 | expect(createIssueResult).toMatchObject({ | ||
| 1301 | data: { | ||
| 1302 | id: "iss_created", | ||
| 1303 | identifier: "ENG-999", | ||
| 1304 | title: "Ship GraphQL field tools", | ||
| 1305 | __typename: "Issue", | ||
| 1306 | }, | ||
| 1307 | errors: [], | ||
| 1308 | }); | ||
| 1309 | |||
| 1310 | const rawRequestResult = (yield* freshEnvironment.toolInvoker.invoke({ | ||
| 1311 | path: "linear.request", | ||
| 1312 | args: { | ||
| 1313 | query: "query Viewer { viewer { id name } }", | ||
| 1314 | }, | ||
| 1315 | })) as { | ||
| 1316 | status: number; | ||
| 1317 | body: { | ||
| 1318 | data: { | ||
| 1319 | viewer: { | ||
| 1320 | id: string; | ||
| 1321 | name: string; | ||
| 1322 | }; | ||
| 1323 | }; | ||
| 1324 | }; | ||
| 1325 | }; | ||
| 1326 | |||
| 1327 | expect(rawRequestResult.status).toBe(200); | ||
| 1328 | expect(rawRequestResult.body).toEqual({ | ||
| 1329 | data: { | ||
| 1330 | viewer: { | ||
| 1331 | id: "usr_123", | ||
| 1332 | name: "Rhys", | ||
| 1333 | }, | ||
| 1334 | }, | ||
| 1335 | }); | ||
| 1336 | expect(graphqlServer.seenAuthHeaders).toEqual([ | ||
| 1337 | "Bearer linear_test_token", | ||
| 1338 | "Bearer linear_test_token", | ||
| 1339 | "Bearer linear_test_token", | ||
| 1340 | "Bearer linear_test_token", | ||
| 1341 | ]); | ||
| 1342 | }), | ||
| 1343 | ); | ||
| 1344 | |||
| 868 | it.scoped("resolves params-backed source auth from invocation context", () => | 1345 | it.scoped("resolves params-backed source auth from invocation context", () => |
| 869 | Effect.gen(function* () { | 1346 | Effect.gen(function* () { |
| 870 | const specServer = yield* makeOpenApiSpecServer; | 1347 | const specServer = yield* makeOpenApiSpecServer; |