@stigmer/runner 3.1.5 → 3.1.7

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.
Files changed (53) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/discover-mcp-server.js +23 -0
  3. package/dist/activities/discover-mcp-server.js.map +1 -1
  4. package/dist/activities/execute-cursor/index.js +39 -1
  5. package/dist/activities/execute-cursor/index.js.map +1 -1
  6. package/dist/activities/execute-cursor/workspace-provision.d.ts +23 -4
  7. package/dist/activities/execute-cursor/workspace-provision.js +17 -6
  8. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  9. package/dist/activities/execute-deep-agent/index.js +8 -1
  10. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  11. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +1 -1
  12. package/dist/activities/execute-deep-agent/post-stream.d.ts +1 -1
  13. package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +1 -1
  14. package/dist/activities/execute-deep-agent/streaming-terminal.d.ts +1 -1
  15. package/dist/activities/execute-deep-agent/streaming.d.ts +1 -1
  16. package/dist/activities/execute-deep-agent/v3-status-builder.d.ts +1 -1
  17. package/dist/shared/execution-status-writer.d.ts +31 -0
  18. package/dist/shared/execution-status-writer.js +43 -0
  19. package/dist/shared/execution-status-writer.js.map +1 -0
  20. package/dist/shared/mcp-oauth-detect.d.ts +53 -0
  21. package/dist/shared/mcp-oauth-detect.js +99 -0
  22. package/dist/shared/mcp-oauth-detect.js.map +1 -0
  23. package/dist/shared/workspace/writeback-coordinator.d.ts +120 -0
  24. package/dist/shared/workspace/writeback-coordinator.js +393 -0
  25. package/dist/shared/workspace/writeback-coordinator.js.map +1 -0
  26. package/package.json +2 -2
  27. package/src/activities/discover-mcp-server.ts +25 -0
  28. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +23 -15
  29. package/src/activities/execute-cursor/index.ts +41 -1
  30. package/src/activities/execute-cursor/workspace-provision.ts +39 -7
  31. package/src/activities/execute-deep-agent/__tests__/post-stream.test.ts +1 -1
  32. package/src/activities/execute-deep-agent/index.ts +8 -1
  33. package/src/activities/execute-deep-agent/inline-publisher.ts +1 -1
  34. package/src/activities/execute-deep-agent/post-stream.ts +1 -1
  35. package/src/activities/execute-deep-agent/status-builder.ts +1 -1
  36. package/src/activities/execute-deep-agent/streaming-side-effects.ts +1 -1
  37. package/src/activities/execute-deep-agent/streaming-terminal.ts +1 -1
  38. package/src/activities/execute-deep-agent/streaming.ts +1 -1
  39. package/src/activities/execute-deep-agent/v3-status-builder.ts +1 -1
  40. package/src/shared/__tests__/mcp-oauth-detect.test.ts +147 -0
  41. package/src/shared/execution-status-writer.ts +56 -0
  42. package/src/shared/mcp-oauth-detect.ts +112 -0
  43. package/src/shared/workspace/__tests__/writeback-coordinator.integration.test.ts +230 -0
  44. package/src/shared/workspace/__tests__/writeback-coordinator.test.ts +477 -0
  45. package/src/{activities/execute-deep-agent → shared/workspace}/writeback-coordinator.ts +206 -94
  46. package/dist/activities/execute-deep-agent/execution-status-writer.d.ts +0 -17
  47. package/dist/activities/execute-deep-agent/execution-status-writer.js +0 -9
  48. package/dist/activities/execute-deep-agent/execution-status-writer.js.map +0 -1
  49. package/dist/activities/execute-deep-agent/writeback-coordinator.d.ts +0 -71
  50. package/dist/activities/execute-deep-agent/writeback-coordinator.js +0 -299
  51. package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +0 -1
  52. package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +0 -426
  53. package/src/activities/execute-deep-agent/execution-status-writer.ts +0 -19
@@ -10,18 +10,38 @@
10
10
  import type { Config } from "../../config.js";
11
11
  import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
12
12
  import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
13
+ import type { ProvisionResult, WorkspaceBackend } from "../../shared/workspace/types.js";
13
14
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
14
15
  import { ensurePlatformDir } from "../../shared/workspace/platform-dir.js";
15
16
  import { resolveSessionWorkspaceRoot } from "../../shared/workspace/session-root.js";
16
17
 
18
+ /** What {@link provisionCursorWorkspace} hands back to the harness. */
19
+ export interface CursorWorkspaceProvision {
20
+ /** The directories the agent should operate in (never empty). */
21
+ readonly workspaceDirs: string[];
22
+ /**
23
+ * Per-entry provision outcomes — carries the git metadata (repo URL, base
24
+ * branch, credential state) the write-back coordinator needs. Empty for a
25
+ * session with no workspace entries.
26
+ */
27
+ readonly provisionResults: ProvisionResult[];
28
+ /**
29
+ * The backend the entries were provisioned through — the write-back
30
+ * coordinator executes its git commands through it.
31
+ */
32
+ readonly workspaceBackend: WorkspaceBackend;
33
+ }
34
+
17
35
  /**
18
36
  * Provision the session's workspace entries for a LOCAL Cursor agent.
19
37
  *
20
38
  * Clones git-repo entries (using the user's GITHUB_TOKEN from the resolved
21
39
  * execution environment) and mounts local-path entries, then returns the
22
- * directories the agent should operate in. A session with no workspace
23
- * entries gets its own empty per-session directory (see session-root.ts)
24
- * never the shared root, which would leak other sessions' files into it.
40
+ * directories the agent should operate in along with the provision results
41
+ * and backend the git write-back coordinator needs. A session with no
42
+ * workspace entries gets its own empty per-session directory (see
43
+ * session-root.ts) — never the shared root, which would leak other sessions'
44
+ * files into it.
25
45
  *
26
46
  * provisionGit is idempotent (it reuses an existing clone), so this is safe
27
47
  * to call on every execution, including multi-turn and HITL reinvocations.
@@ -31,13 +51,21 @@ export async function provisionCursorWorkspace(
31
51
  session: Session,
32
52
  envVars: Record<string, string>,
33
53
  sessionId: string,
34
- ): Promise<string[]> {
54
+ ): Promise<CursorWorkspaceProvision> {
35
55
  const entries = session.spec?.workspaceEntries ?? [];
56
+ const platformDir = await ensurePlatformDir(sessionId);
57
+
36
58
  if (entries.length === 0) {
37
- return [await resolveSessionWorkspaceRoot(config.workspaceRootDir, entries, sessionId)];
59
+ const sessionRoot = await resolveSessionWorkspaceRoot(
60
+ config.workspaceRootDir, entries, sessionId,
61
+ );
62
+ return {
63
+ workspaceDirs: [sessionRoot],
64
+ provisionResults: [],
65
+ workspaceBackend: new LocalWorkspaceBackend(sessionRoot, platformDir),
66
+ };
38
67
  }
39
68
 
40
- const platformDir = await ensurePlatformDir(sessionId);
41
69
  const backend = new LocalWorkspaceBackend(config.workspaceRootDir, platformDir);
42
70
 
43
71
  const provisioner = new WorkspaceProvisioner();
@@ -53,5 +81,9 @@ export async function provisionCursorWorkspace(
53
81
  .map((result) => result.rootDir)
54
82
  .filter((dir): dir is string => Boolean(dir));
55
83
 
56
- return dirs.length > 0 ? dirs : [config.workspaceRootDir];
84
+ return {
85
+ workspaceDirs: dirs.length > 0 ? dirs : [config.workspaceRootDir],
86
+ provisionResults: results,
87
+ workspaceBackend: backend,
88
+ };
57
89
  }
@@ -3,7 +3,7 @@ import { create } from "@bufbuild/protobuf";
3
3
  import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
4
4
  import { processPostStream } from "../post-stream.js";
5
5
  import type { InlinePublisher } from "../inline-publisher.js";
6
- import type { WriteBackCoordinator } from "../writeback-coordinator.js";
6
+ import type { WriteBackCoordinator } from "../../../shared/workspace/writeback-coordinator.js";
7
7
 
8
8
  function mockInlinePublisher(): InlinePublisher {
9
9
  return {
@@ -45,7 +45,7 @@ import { streamExecution, type StreamResult } from "./streaming.js";
45
45
  import { loadStreamingConfig } from "../../shared/streaming-scheduler.js";
46
46
  import { StatusBuilder } from "./status-builder.js";
47
47
  import { InlinePublisher } from "./inline-publisher.js";
48
- import { WriteBackCoordinator } from "./writeback-coordinator.js";
48
+ import { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
49
49
  import { processPostStream } from "./post-stream.js";
50
50
  import { resolveResumeInput, type GraphStateSnapshot } from "./hitl.js";
51
51
  import { captureApprovalArtifacts } from "./approval-file-change.js";
@@ -184,6 +184,13 @@ export function createDeepAgentActivities(config: Config) {
184
184
  ? new WriteBackCoordinator({
185
185
  statusWriter: statusBuilder,
186
186
  executionId,
187
+ // Branch/PR are session-scoped: every turn of this session
188
+ // appends commits to the same stigmer/<session-id> branch.
189
+ sessionId: setup.session.metadata?.id ?? "",
190
+ // The PR API token, plumbed from the resolved execution env —
191
+ // gitMetadata.repoUrl is token-stripped by construction, so the
192
+ // coordinator can never recover it from provisioning state.
193
+ githubToken: setup.mergedEnvVars.GITHUB_TOKEN ?? "",
187
194
  provisionResults: setup.provisionResults,
188
195
  workspaceEntries: workspaceEntries as any,
189
196
  workspaceBackend: setup.workspaceBackend,
@@ -20,7 +20,7 @@ import {
20
20
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
21
21
  import type { ArtifactStorage } from "../../shared/artifact-storage.js";
22
22
  import type { WorkspaceBackend } from "../../shared/workspace/types.js";
23
- import type { ExecutionStatusWriter } from "./execution-status-writer.js";
23
+ import type { ExecutionStatusWriter } from "../../shared/execution-status-writer.js";
24
24
  import { utcTimestamp } from "../../shared/status.js";
25
25
  import { isSecretLikePath } from "../../shared/filereview/secret-paths.js";
26
26
 
@@ -13,7 +13,7 @@
13
13
  import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
14
14
  import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
15
15
  import type { InlinePublisher } from "./inline-publisher.js";
16
- import type { WriteBackCoordinator } from "./writeback-coordinator.js";
16
+ import type { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
17
17
  import { autoPublishWrittenFiles } from "./auto-publish.js";
18
18
 
19
19
  export interface PostStreamOptions {
@@ -31,7 +31,7 @@ import { classifyTool } from "../../shared/tool-kind.js";
31
31
  import { applyTodoUpdate } from "../../shared/todos.js";
32
32
  import { ExecutionState } from "./execution-state.js";
33
33
  import { utcTimestamp } from "../../shared/status.js";
34
- import type { ExecutionStatusWriter } from "./execution-status-writer.js";
34
+ import type { ExecutionStatusWriter } from "../../shared/execution-status-writer.js";
35
35
  import {
36
36
  UsageAccumulator,
37
37
  extractToolResult,
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { V3ProtocolEvent } from "./v3-event-recorder.js";
10
10
  import type { InlinePublisher } from "./inline-publisher.js";
11
- import type { WriteBackCoordinator } from "./writeback-coordinator.js";
11
+ import type { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
12
12
  import { extractFilePath, isFileModifyingTool } from "../../shared/file-tools.js";
13
13
 
14
14
  interface CachedToolInput {
@@ -8,7 +8,7 @@
8
8
  import { create } from "@bufbuild/protobuf";
9
9
  import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
10
10
  import { ExecutionPhase, MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
11
- import type { ExecutionStatusWriter } from "./execution-status-writer.js";
11
+ import type { ExecutionStatusWriter } from "../../shared/execution-status-writer.js";
12
12
  import type { StreamResult } from "./streaming.js";
13
13
  import { slimStatus, utcTimestamp } from "../../shared/status.js";
14
14
 
@@ -39,7 +39,7 @@ import type { ToolOutputOffloadContext } from "../../shared/status-offload.js";
39
39
  import type { StigmerClient } from "../../client/stigmer-client.js";
40
40
  import type { GracefulStopMiddleware } from "../../middleware/index.js";
41
41
  import type { InlinePublisher } from "./inline-publisher.js";
42
- import type { WriteBackCoordinator } from "./writeback-coordinator.js";
42
+ import type { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
43
43
  import { createV2EventRecorder } from "./event-recorder.js";
44
44
  import { streamExecutionV3 } from "./streaming-v3.js";
45
45
  import { extractFilePath, isFileModifyingTool } from "../../shared/file-tools.js";
@@ -30,7 +30,7 @@ import { classifyTool } from "../../shared/tool-kind.js";
30
30
  import { applyTodoUpdate } from "../../shared/todos.js";
31
31
  import { ExecutionState } from "./execution-state.js";
32
32
  import { utcTimestamp } from "../../shared/status.js";
33
- import type { ExecutionStatusWriter } from "./execution-status-writer.js";
33
+ import type { ExecutionStatusWriter } from "../../shared/execution-status-writer.js";
34
34
  import type { ApprovalPolicyProvider } from "./status-builder.js";
35
35
  import type { StigmerRunEvent, V3UsagePayload } from "./v3-events.js";
36
36
  import { namespaceDepth } from "./v3-events.js";
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Unit tests for the HTTP MCP OAuth-challenge classifier.
3
+ *
4
+ * Verifies that `detectOAuthChallenge` recognizes a real MCP OAuth challenge
5
+ * (401 + `WWW-Authenticate: Bearer ...` per RFC 9728), and does NOT misfire on
6
+ * a plain invalid-token 401, a success, a non-OAuth error, or a network fault —
7
+ * so it can only ever turn an opaque failure into a clearer one, never mask a
8
+ * different cause.
9
+ */
10
+
11
+ import { afterEach, describe, it, expect, vi } from "vitest";
12
+ import {
13
+ OAuthRequiredError,
14
+ detectOAuthChallenge,
15
+ isOAuthChallenge,
16
+ parseResourceMetadataUrl,
17
+ } from "../mcp-oauth-detect.js";
18
+
19
+ function mockFetchOnce(response: {
20
+ status: number;
21
+ wwwAuthenticate?: string;
22
+ }): void {
23
+ const headers = new Headers();
24
+ if (response.wwwAuthenticate !== undefined) {
25
+ headers.set("www-authenticate", response.wwwAuthenticate);
26
+ }
27
+ vi.stubGlobal(
28
+ "fetch",
29
+ vi.fn(async () =>
30
+ new Response(null, { status: response.status, headers }),
31
+ ),
32
+ );
33
+ }
34
+
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals();
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ describe("isOAuthChallenge", () => {
41
+ it("matches the MCP auth spec Bearer + OAuth realm challenge", () => {
42
+ expect(
43
+ isOAuthChallenge('Bearer realm="OAuth", resource_metadata="https://x/.well-known/oauth-protected-resource"'),
44
+ ).toBe(true);
45
+ });
46
+
47
+ it("matches a Bearer challenge that only advertises resource_metadata", () => {
48
+ expect(
49
+ isOAuthChallenge('Bearer resource_metadata="https://x/.well-known/oauth-protected-resource"'),
50
+ ).toBe(true);
51
+ });
52
+
53
+ it("does not match a plain Bearer 401 (invalid API key, not an OAuth requirement)", () => {
54
+ expect(isOAuthChallenge('Bearer error="invalid_token"')).toBe(false);
55
+ });
56
+
57
+ it("does not match a non-Bearer scheme", () => {
58
+ expect(isOAuthChallenge('Basic realm="oauth"')).toBe(false);
59
+ });
60
+
61
+ it("is empty-safe", () => {
62
+ expect(isOAuthChallenge("")).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe("parseResourceMetadataUrl", () => {
67
+ it("extracts the resource_metadata URL", () => {
68
+ expect(
69
+ parseResourceMetadataUrl(
70
+ 'Bearer realm="OAuth", resource_metadata="https://mcp.notion.com/.well-known/oauth-protected-resource/mcp"',
71
+ ),
72
+ ).toBe("https://mcp.notion.com/.well-known/oauth-protected-resource/mcp");
73
+ });
74
+
75
+ it("returns undefined when absent", () => {
76
+ expect(parseResourceMetadataUrl('Bearer realm="OAuth"')).toBeUndefined();
77
+ });
78
+ });
79
+
80
+ describe("detectOAuthChallenge", () => {
81
+ it("returns an OAuthRequiredError for a 401 OAuth challenge", async () => {
82
+ mockFetchOnce({
83
+ status: 401,
84
+ wwwAuthenticate:
85
+ 'Bearer realm="OAuth", resource_metadata="https://mcp.notion.com/.well-known/oauth-protected-resource/mcp"',
86
+ });
87
+
88
+ const result = await detectOAuthChallenge("https://mcp.notion.com/mcp", {}, "notion");
89
+
90
+ expect(result).toBeInstanceOf(OAuthRequiredError);
91
+ expect(result?.serverSlug).toBe("notion");
92
+ expect(result?.resourceMetadataUrl).toBe(
93
+ "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp",
94
+ );
95
+ // The message is self-contained and carries the "requires OAuth" marker the
96
+ // connect wrappers key on.
97
+ expect(result?.message).toContain("requires OAuth");
98
+ expect(result?.message).toContain("notion");
99
+ });
100
+
101
+ it("returns null for a 401 that is NOT an OAuth challenge (invalid static token)", async () => {
102
+ mockFetchOnce({ status: 401, wwwAuthenticate: 'Bearer error="invalid_token"' });
103
+ expect(
104
+ await detectOAuthChallenge("https://api.example.com/mcp", {}, "example"),
105
+ ).toBeNull();
106
+ });
107
+
108
+ it("returns null on a non-401 response", async () => {
109
+ mockFetchOnce({ status: 200 });
110
+ expect(
111
+ await detectOAuthChallenge("https://api.example.com/mcp", {}, "example"),
112
+ ).toBeNull();
113
+ });
114
+
115
+ it("returns null (never throws) when the probe itself fails", async () => {
116
+ vi.stubGlobal(
117
+ "fetch",
118
+ vi.fn(async () => {
119
+ throw new Error("network down");
120
+ }),
121
+ );
122
+ expect(
123
+ await detectOAuthChallenge("https://api.example.com/mcp", {}, "example"),
124
+ ).toBeNull();
125
+ });
126
+
127
+ it("forwards the resolved headers to the probe request", async () => {
128
+ const fetchMock = vi.fn(
129
+ async (_url: string | URL, _init?: RequestInit) =>
130
+ new Response(null, { status: 200 }),
131
+ );
132
+ vi.stubGlobal("fetch", fetchMock);
133
+
134
+ await detectOAuthChallenge(
135
+ "https://api.example.com/mcp",
136
+ { Authorization: "Bearer stale-token" },
137
+ "example",
138
+ );
139
+
140
+ expect(fetchMock).toHaveBeenCalledOnce();
141
+ const init = fetchMock.mock.calls[0]?.[1];
142
+ expect((init?.headers as Record<string, string>).Authorization).toBe(
143
+ "Bearer stale-token",
144
+ );
145
+ expect(init?.method).toBe("POST");
146
+ });
147
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared interface for proto mutation consumed by side-effect classes
3
+ * (InlinePublisher, WriteBackCoordinator) and streaming loops.
4
+ *
5
+ * The deep-agent harness implements it on its v2 StatusBuilder and
6
+ * V3StatusBuilder; the Cursor harness — which mutates a bare status proto
7
+ * with no builder — uses {@link statusProtoWriter}. Side-effect classes
8
+ * depend only on this interface, never on a specific implementation.
9
+ */
10
+
11
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
12
+ import type { ExecutionArtifact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
13
+ import type { WorkspaceWriteBack } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
14
+
15
+ export interface ExecutionStatusWriter {
16
+ readonly currentStatus: AgentExecutionStatus;
17
+ readonly forceNextUpdate: boolean;
18
+ clearForceFlag(): void;
19
+ addArtifact(artifact: ExecutionArtifact): void;
20
+ addWriteBack(wb: WorkspaceWriteBack): void;
21
+ }
22
+
23
+ /**
24
+ * An {@link ExecutionStatusWriter} over a bare status proto, for harnesses
25
+ * that mutate the status directly instead of through a builder (the Cursor
26
+ * harness). Mutations apply synchronously to the given proto; the caller's
27
+ * own persist points pick them up — the force flag is a streaming-loop
28
+ * concern the direct-mutation style has no use for, so it stays `false`.
29
+ *
30
+ * Write-backs upsert by `workspaceEntryName` — the same contract as the
31
+ * deep-agent `StatusBuilder.addWriteBack`: each git-backed workspace entry
32
+ * carries at most one record, progressing through phases.
33
+ */
34
+ export function statusProtoWriter(status: AgentExecutionStatus): ExecutionStatusWriter {
35
+ return {
36
+ get currentStatus() {
37
+ return status;
38
+ },
39
+ forceNextUpdate: false,
40
+ clearForceFlag() {},
41
+ addArtifact(artifact: ExecutionArtifact) {
42
+ status.artifacts.push(artifact);
43
+ },
44
+ addWriteBack(wb: WorkspaceWriteBack) {
45
+ const backs = status.workspaceWriteBacks;
46
+ const idx = backs.findIndex(
47
+ (b) => b.workspaceEntryName === wb.workspaceEntryName,
48
+ );
49
+ if (idx >= 0) {
50
+ backs[idx] = wb;
51
+ } else {
52
+ backs.push(wb);
53
+ }
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * OAuth-challenge classification for HTTP MCP endpoints.
3
+ *
4
+ * When an HTTP MCP endpoint requires OAuth (per the MCP Authorization spec /
5
+ * RFC 9728), an unauthenticated or statically-tokened request is answered with
6
+ * `401` and a `WWW-Authenticate: Bearer ...` header pointing at OAuth protected
7
+ * resource metadata. The MCP client SDK surfaces this only as an opaque
8
+ * aggregate error ("unhandled errors in a TaskGroup"), which tells the user
9
+ * nothing.
10
+ *
11
+ * This module turns that opaque failure into a precise, actionable signal: it
12
+ * re-probes the endpoint once (only on the failure path, so the happy path pays
13
+ * nothing) and, if it sees an OAuth challenge, returns an {@link
14
+ * OAuthRequiredError} whose message tells the user to connect via OAuth instead
15
+ * of a manual token.
16
+ *
17
+ * Kept transport-library-agnostic (uses global `fetch`) so it is reusable by
18
+ * connect-time discovery, execution-time error enrichment, and any other caller
19
+ * that holds an MCP endpoint URL + headers.
20
+ */
21
+
22
+ const OAUTH_PROBE_TIMEOUT_MS = 10_000;
23
+
24
+ /**
25
+ * Raised when an HTTP MCP endpoint answers with an OAuth authentication
26
+ * challenge. The message is self-contained and user-facing: it survives the
27
+ * Temporal boundary and is shown by the connect error wrappers verbatim.
28
+ *
29
+ * The literal phrase "requires OAuth" is a stable marker the Go/Java connect
30
+ * wrappers match to avoid appending a generic "check your credentials" suffix.
31
+ */
32
+ export class OAuthRequiredError extends Error {
33
+ constructor(
34
+ public readonly serverSlug: string,
35
+ public readonly resourceMetadataUrl?: string,
36
+ ) {
37
+ super(
38
+ `MCP server '${serverSlug}' requires OAuth: its endpoint returned an ` +
39
+ `authentication challenge (HTTP 401). A manually-entered API token will ` +
40
+ `not work here — connect it with the OAuth "Sign in" flow instead.`,
41
+ );
42
+ this.name = "OAuthRequiredError";
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Whether a `WWW-Authenticate` header value is an OAuth challenge.
48
+ *
49
+ * The MCP auth spec emits `Bearer realm="OAuth", resource_metadata="..."`. We
50
+ * require the `Bearer` scheme plus either an OAuth realm or a resource-metadata
51
+ * pointer, so a plain `Bearer` 401 from a static-token API (an invalid key, not
52
+ * an OAuth requirement) is not misclassified.
53
+ */
54
+ export function isOAuthChallenge(wwwAuthenticate: string): boolean {
55
+ const value = wwwAuthenticate.toLowerCase();
56
+ if (!value.includes("bearer")) return false;
57
+ return value.includes("oauth") || value.includes("resource_metadata");
58
+ }
59
+
60
+ /** Extract the `resource_metadata` URL from a `WWW-Authenticate` value, if present. */
61
+ export function parseResourceMetadataUrl(
62
+ wwwAuthenticate: string,
63
+ ): string | undefined {
64
+ const match = /resource_metadata="([^"]+)"/i.exec(wwwAuthenticate);
65
+ return match?.[1];
66
+ }
67
+
68
+ /**
69
+ * Probe an HTTP MCP endpoint once to decide whether its failure is an OAuth
70
+ * challenge. Returns an {@link OAuthRequiredError} to throw, or `null` when the
71
+ * endpoint is not asking for OAuth (so the caller rethrows the original error).
72
+ *
73
+ * Never throws: any probe/network failure returns `null` so this classification
74
+ * step can never mask or replace the original discovery error.
75
+ */
76
+ export async function detectOAuthChallenge(
77
+ url: string,
78
+ headers: Record<string, string> | undefined,
79
+ slug: string,
80
+ ): Promise<OAuthRequiredError | null> {
81
+ try {
82
+ const response = await fetch(url, {
83
+ method: "POST",
84
+ headers: {
85
+ "Content-Type": "application/json",
86
+ Accept: "application/json, text/event-stream",
87
+ ...(headers ?? {}),
88
+ },
89
+ // A minimal MCP initialize request — enough to trigger the endpoint's
90
+ // auth check without establishing a session.
91
+ body: JSON.stringify({
92
+ jsonrpc: "2.0",
93
+ id: 1,
94
+ method: "initialize",
95
+ params: {},
96
+ }),
97
+ signal: AbortSignal.timeout(OAUTH_PROBE_TIMEOUT_MS),
98
+ });
99
+
100
+ if (response.status !== 401) return null;
101
+
102
+ const wwwAuthenticate = response.headers.get("www-authenticate") ?? "";
103
+ if (!isOAuthChallenge(wwwAuthenticate)) return null;
104
+
105
+ return new OAuthRequiredError(
106
+ slug,
107
+ parseResourceMetadataUrl(wwwAuthenticate),
108
+ );
109
+ } catch {
110
+ return null;
111
+ }
112
+ }