@stigmer/cli 3.13.0 → 3.14.0-dev.20260910084630
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/auth/login.d.ts.map +1 -1
- package/auth/login.js +30 -5
- package/auth/login.js.map +1 -1
- package/auth/token.d.ts +10 -5
- package/auth/token.d.ts.map +1 -1
- package/auth/token.js +30 -21
- package/auth/token.js.map +1 -1
- package/commands/apikey/index.d.ts.map +1 -1
- package/commands/apikey/index.js +11 -5
- package/commands/apikey/index.js.map +1 -1
- package/commands/auth/index.d.ts.map +1 -1
- package/commands/auth/index.js +19 -8
- package/commands/auth/index.js.map +1 -1
- package/commands/config/backend.d.ts.map +1 -1
- package/commands/config/backend.js +134 -12
- package/commands/config/backend.js.map +1 -1
- package/commands/connect.d.ts.map +1 -1
- package/commands/connect.js +6 -3
- package/commands/connect.js.map +1 -1
- package/commands/share.d.ts.map +1 -1
- package/commands/share.js +8 -6
- package/commands/share.js.map +1 -1
- package/config/config.d.ts +56 -7
- package/config/config.d.ts.map +1 -1
- package/config/config.js +115 -16
- package/config/config.js.map +1 -1
- package/config/index.d.ts +1 -1
- package/config/index.d.ts.map +1 -1
- package/config/index.js +1 -1
- package/config/index.js.map +1 -1
- package/config/keys.d.ts +1 -1
- package/config/keys.d.ts.map +1 -1
- package/config/keys.js +29 -6
- package/config/keys.js.map +1 -1
- package/config/resolve.d.ts +28 -20
- package/config/resolve.d.ts.map +1 -1
- package/config/resolve.js +74 -36
- package/config/resolve.js.map +1 -1
- package/local/seedpack/content.d.ts +3 -3
- package/local/seedpack/content.d.ts.map +1 -1
- package/local/seedpack/content.js +19 -13
- package/local/seedpack/content.js.map +1 -1
- package/package.json +5 -5
- package/resources/connect/connect.d.ts +4 -3
- package/resources/connect/connect.d.ts.map +1 -1
- package/resources/connect/connect.js +17 -5
- package/resources/connect/connect.js.map +1 -1
- package/resources/connect/oauth.d.ts +5 -2
- package/resources/connect/oauth.d.ts.map +1 -1
- package/resources/connect/oauth.js +5 -4
- package/resources/connect/oauth.js.map +1 -1
- package/src/auth/login.ts +54 -9
- package/src/auth/token.test.ts +69 -13
- package/src/auth/token.ts +46 -22
- package/src/client/client.test.ts +10 -2
- package/src/commands/apikey/index.ts +42 -11
- package/src/commands/auth/index.ts +52 -14
- package/src/commands/config/backend.test.ts +122 -0
- package/src/commands/config/backend.ts +195 -14
- package/src/commands/connect.test.ts +22 -4
- package/src/commands/connect.ts +37 -13
- package/src/commands/share.ts +36 -16
- package/src/config/config.test.ts +62 -6
- package/src/config/config.ts +165 -24
- package/src/config/index.ts +6 -0
- package/src/config/keys.test.ts +27 -4
- package/src/config/keys.ts +46 -9
- package/src/config/resolve.test.ts +135 -34
- package/src/config/resolve.ts +83 -36
- package/src/local/seedpack/content.ts +52 -21
- package/src/resources/connect/connect.integration.test.ts +79 -26
- package/src/resources/connect/connect.ts +72 -19
- package/src/resources/connect/oauth.test.ts +38 -12
- package/src/resources/connect/oauth.ts +27 -11
|
@@ -18,7 +18,11 @@ import { McpServerQueryController } from "@stigmer/protos/ai/stigmer/agentic/mcp
|
|
|
18
18
|
import { McpServerAuthSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
|
|
19
19
|
import type { Stigmer } from "@stigmer/sdk";
|
|
20
20
|
import { createNodeClient, normalizeEndpoint } from "@stigmer/sdk/node";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
createServer as createHttp2Server,
|
|
23
|
+
type Http2Server,
|
|
24
|
+
type ServerHttp2Session,
|
|
25
|
+
} from "node:http2";
|
|
22
26
|
import type { AddressInfo } from "node:net";
|
|
23
27
|
import { fileURLToPath } from "node:url";
|
|
24
28
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
@@ -26,7 +30,9 @@ import { UsageError } from "../../errors/index.js";
|
|
|
26
30
|
import { connectMcpServer } from "./connect.js";
|
|
27
31
|
import { renderConnectResult } from "./display.js";
|
|
28
32
|
|
|
29
|
-
const FIXTURE = fileURLToPath(
|
|
33
|
+
const FIXTURE = fileURLToPath(
|
|
34
|
+
new URL("./__fixtures__/stdio-server.mjs", import.meta.url),
|
|
35
|
+
);
|
|
30
36
|
|
|
31
37
|
let backend: Http2Server;
|
|
32
38
|
let client: Stigmer;
|
|
@@ -48,7 +54,10 @@ beforeEach(() => {
|
|
|
48
54
|
servedSpec = create(McpServerSchema, {
|
|
49
55
|
metadata: { id: "mcp_1", name: "github", slug: "github", org: "acme" },
|
|
50
56
|
spec: {
|
|
51
|
-
serverType: {
|
|
57
|
+
serverType: {
|
|
58
|
+
case: "stdio",
|
|
59
|
+
value: { command: process.execPath, args: [FIXTURE] },
|
|
60
|
+
},
|
|
52
61
|
env: { GITHUB_TOKEN: { isSecret: true } },
|
|
53
62
|
},
|
|
54
63
|
});
|
|
@@ -61,7 +70,9 @@ const updatedServer = create(McpServerSchema, {
|
|
|
61
70
|
status: {
|
|
62
71
|
discoveredCapabilities: {
|
|
63
72
|
tools: [{ name: "search_issues", description: "search issues" }],
|
|
64
|
-
resourceTemplates: [
|
|
73
|
+
resourceTemplates: [
|
|
74
|
+
{ name: "issue", uriTemplate: "github:///issues/{id}" },
|
|
75
|
+
],
|
|
65
76
|
},
|
|
66
77
|
},
|
|
67
78
|
});
|
|
@@ -71,16 +82,23 @@ beforeAll(async () => {
|
|
|
71
82
|
router.service(McpServerQueryController, {
|
|
72
83
|
getByReference: () => servedSpec,
|
|
73
84
|
get: () => servedSpec,
|
|
74
|
-
getOAuthGrantStatus: () =>
|
|
85
|
+
getOAuthGrantStatus: () =>
|
|
86
|
+
create(GetOAuthGrantStatusOutputSchema, { connected: grantConnected }),
|
|
75
87
|
});
|
|
76
88
|
router.service(McpServerCommandController, {
|
|
77
89
|
// Mirror the backend's protovalidate rule (org min_len=1): reject an empty
|
|
78
90
|
// org so a regression that drops org fails loudly here instead of silently
|
|
79
91
|
// passing (the mock adapter does not run protovalidate on its own).
|
|
80
92
|
connect: async (req) => {
|
|
81
|
-
if (req.org === "")
|
|
93
|
+
if (req.org === "")
|
|
94
|
+
throw new ConnectError(
|
|
95
|
+
"org – value length must be at least 1 characters",
|
|
96
|
+
Code.InvalidArgument,
|
|
97
|
+
);
|
|
82
98
|
if (connectDelayMs > 0) {
|
|
83
|
-
await new Promise((resolve) =>
|
|
99
|
+
await new Promise((resolve) =>
|
|
100
|
+
setTimeout(resolve, connectDelayMs).unref(),
|
|
101
|
+
);
|
|
84
102
|
}
|
|
85
103
|
connectCalls.push(req);
|
|
86
104
|
return updatedServer;
|
|
@@ -95,7 +113,9 @@ beforeAll(async () => {
|
|
|
95
113
|
});
|
|
96
114
|
await new Promise<void>((resolve) => backend.listen(0, "127.0.0.1", resolve));
|
|
97
115
|
const port = (backend.address() as AddressInfo).port;
|
|
98
|
-
client = createNodeClient({
|
|
116
|
+
client = createNodeClient({
|
|
117
|
+
baseUrl: normalizeEndpoint(`127.0.0.1:${port}`),
|
|
118
|
+
});
|
|
99
119
|
});
|
|
100
120
|
|
|
101
121
|
afterAll(async () => {
|
|
@@ -111,7 +131,8 @@ describe("connect push path", () => {
|
|
|
111
131
|
timeoutMs: 30_000,
|
|
112
132
|
dryRun: false,
|
|
113
133
|
envOverrides: ["GITHUB_TOKEN=ghp-override"],
|
|
114
|
-
|
|
134
|
+
consoleURL: "https://app.stigmer.ai",
|
|
135
|
+
probeLocalConsole: false,
|
|
115
136
|
interactive: false,
|
|
116
137
|
});
|
|
117
138
|
|
|
@@ -124,7 +145,9 @@ describe("connect push path", () => {
|
|
|
124
145
|
expect(connectCalls[0].runtimeEnv.GITHUB_TOKEN.isSecret).toBe(true);
|
|
125
146
|
|
|
126
147
|
expect(result.updated?.metadata?.id).toBe("mcp_1");
|
|
127
|
-
expect(result.capabilities?.tools.map((t) => t.name)).toEqual([
|
|
148
|
+
expect(result.capabilities?.tools.map((t) => t.name)).toEqual([
|
|
149
|
+
"search_issues",
|
|
150
|
+
]);
|
|
128
151
|
});
|
|
129
152
|
});
|
|
130
153
|
|
|
@@ -139,10 +162,13 @@ describe("--timeout bounds the server-side connect (issue #239)", () => {
|
|
|
139
162
|
pushTimeoutMs: 150,
|
|
140
163
|
dryRun: false,
|
|
141
164
|
envOverrides: ["GITHUB_TOKEN=ghp-x"],
|
|
142
|
-
|
|
165
|
+
consoleURL: "https://app.stigmer.ai",
|
|
166
|
+
probeLocalConsole: false,
|
|
143
167
|
interactive: false,
|
|
144
168
|
}),
|
|
145
|
-
).rejects.toThrow(
|
|
169
|
+
).rejects.toThrow(
|
|
170
|
+
/Stopped waiting for the connect of MCP server 'github' after 0\.15s/,
|
|
171
|
+
);
|
|
146
172
|
});
|
|
147
173
|
|
|
148
174
|
it("does not bound the wait when pushTimeoutMs is unset (default --timeout)", async () => {
|
|
@@ -157,7 +183,8 @@ describe("--timeout bounds the server-side connect (issue #239)", () => {
|
|
|
157
183
|
timeoutMs: 100,
|
|
158
184
|
dryRun: false,
|
|
159
185
|
envOverrides: ["GITHUB_TOKEN=ghp-x"],
|
|
160
|
-
|
|
186
|
+
consoleURL: "https://app.stigmer.ai",
|
|
187
|
+
probeLocalConsole: false,
|
|
161
188
|
interactive: false,
|
|
162
189
|
});
|
|
163
190
|
expect(result.updated?.metadata?.id).toBe("mcp_1");
|
|
@@ -166,7 +193,9 @@ describe("--timeout bounds the server-side connect (issue #239)", () => {
|
|
|
166
193
|
|
|
167
194
|
describe("OAuth guidance gate", () => {
|
|
168
195
|
beforeEach(() => {
|
|
169
|
-
servedSpec.spec!.auth = create(McpServerAuthSchema, {
|
|
196
|
+
servedSpec.spec!.auth = create(McpServerAuthSchema, {
|
|
197
|
+
targetEnvVar: "GITHUB_TOKEN",
|
|
198
|
+
});
|
|
170
199
|
});
|
|
171
200
|
|
|
172
201
|
it("stops with actionable guidance when auth is required off an interactive terminal", async () => {
|
|
@@ -177,7 +206,8 @@ describe("OAuth guidance gate", () => {
|
|
|
177
206
|
timeoutMs: 30_000,
|
|
178
207
|
dryRun: false,
|
|
179
208
|
envOverrides: [],
|
|
180
|
-
|
|
209
|
+
consoleURL: "https://app.stigmer.ai",
|
|
210
|
+
probeLocalConsole: false,
|
|
181
211
|
interactive: false,
|
|
182
212
|
}),
|
|
183
213
|
).rejects.toThrow(UsageError);
|
|
@@ -192,7 +222,8 @@ describe("OAuth guidance gate", () => {
|
|
|
192
222
|
timeoutMs: 30_000,
|
|
193
223
|
dryRun: false,
|
|
194
224
|
envOverrides: [],
|
|
195
|
-
|
|
225
|
+
consoleURL: "https://app.stigmer.ai",
|
|
226
|
+
probeLocalConsole: false,
|
|
196
227
|
interactive: false,
|
|
197
228
|
});
|
|
198
229
|
expect(connectCalls).toHaveLength(1);
|
|
@@ -205,7 +236,8 @@ describe("OAuth guidance gate", () => {
|
|
|
205
236
|
timeoutMs: 30_000,
|
|
206
237
|
dryRun: false,
|
|
207
238
|
envOverrides: ["GITHUB_TOKEN=ghp-x"],
|
|
208
|
-
|
|
239
|
+
consoleURL: "https://app.stigmer.ai",
|
|
240
|
+
probeLocalConsole: false,
|
|
209
241
|
interactive: false,
|
|
210
242
|
});
|
|
211
243
|
expect(connectCalls).toHaveLength(1);
|
|
@@ -227,7 +259,8 @@ describe("oauth_only servers reject the manual-token routes", () => {
|
|
|
227
259
|
timeoutMs: 30_000,
|
|
228
260
|
dryRun: false,
|
|
229
261
|
envOverrides: ["GITHUB_TOKEN=ghp-x"],
|
|
230
|
-
|
|
262
|
+
consoleURL: "https://app.stigmer.ai",
|
|
263
|
+
probeLocalConsole: false,
|
|
231
264
|
interactive: false,
|
|
232
265
|
}).catch((e: unknown) => e);
|
|
233
266
|
|
|
@@ -245,7 +278,8 @@ describe("oauth_only servers reject the manual-token routes", () => {
|
|
|
245
278
|
timeoutMs: 30_000,
|
|
246
279
|
dryRun: false,
|
|
247
280
|
envOverrides: [],
|
|
248
|
-
|
|
281
|
+
consoleURL: "https://app.stigmer.ai",
|
|
282
|
+
probeLocalConsole: false,
|
|
249
283
|
interactive: false,
|
|
250
284
|
}).catch((e: unknown) => e);
|
|
251
285
|
|
|
@@ -261,7 +295,8 @@ describe("oauth_only servers reject the manual-token routes", () => {
|
|
|
261
295
|
timeoutMs: 10_000,
|
|
262
296
|
dryRun: true,
|
|
263
297
|
envOverrides: [],
|
|
264
|
-
|
|
298
|
+
consoleURL: "https://app.stigmer.ai",
|
|
299
|
+
probeLocalConsole: false,
|
|
265
300
|
interactive: false,
|
|
266
301
|
}).catch((e: unknown) => e);
|
|
267
302
|
|
|
@@ -280,13 +315,17 @@ describe("dry-run path", () => {
|
|
|
280
315
|
timeoutMs: 10_000,
|
|
281
316
|
dryRun: true,
|
|
282
317
|
envOverrides: [],
|
|
283
|
-
|
|
318
|
+
consoleURL: "https://app.stigmer.ai",
|
|
319
|
+
probeLocalConsole: false,
|
|
284
320
|
interactive: false,
|
|
285
321
|
});
|
|
286
322
|
|
|
287
323
|
expect(connectCalls).toHaveLength(0);
|
|
288
324
|
expect(result.updated).toBeUndefined();
|
|
289
|
-
expect(result.capabilities?.tools.map((t) => t.name)).toEqual([
|
|
325
|
+
expect(result.capabilities?.tools.map((t) => t.name)).toEqual([
|
|
326
|
+
"echo",
|
|
327
|
+
"noop",
|
|
328
|
+
]);
|
|
290
329
|
|
|
291
330
|
const lines: string[] = [];
|
|
292
331
|
renderConnectResult(result, (l) => lines.push(l), false);
|
|
@@ -301,13 +340,26 @@ describe("dry-run path", () => {
|
|
|
301
340
|
// Server references a declared env var in its args, but it is not set.
|
|
302
341
|
delete process.env.NEEDED_DIR;
|
|
303
342
|
servedSpec = create(McpServerSchema, {
|
|
304
|
-
metadata: {
|
|
343
|
+
metadata: {
|
|
344
|
+
id: "mcp_1",
|
|
345
|
+
name: "filesystem",
|
|
346
|
+
slug: "filesystem",
|
|
347
|
+
org: "acme",
|
|
348
|
+
},
|
|
305
349
|
spec: {
|
|
306
350
|
serverType: {
|
|
307
351
|
case: "stdio",
|
|
308
|
-
value: {
|
|
352
|
+
value: {
|
|
353
|
+
command: process.execPath,
|
|
354
|
+
args: [FIXTURE, "${NEEDED_DIR}"],
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
env: {
|
|
358
|
+
NEEDED_DIR: {
|
|
359
|
+
isSecret: false,
|
|
360
|
+
description: "Root directory the server may access",
|
|
361
|
+
},
|
|
309
362
|
},
|
|
310
|
-
env: { NEEDED_DIR: { isSecret: false, description: "Root directory the server may access" } },
|
|
311
363
|
},
|
|
312
364
|
});
|
|
313
365
|
|
|
@@ -317,7 +369,8 @@ describe("dry-run path", () => {
|
|
|
317
369
|
timeoutMs: 10_000,
|
|
318
370
|
dryRun: true,
|
|
319
371
|
envOverrides: [],
|
|
320
|
-
|
|
372
|
+
consoleURL: "https://app.stigmer.ai",
|
|
373
|
+
probeLocalConsole: false,
|
|
321
374
|
interactive: false,
|
|
322
375
|
}).catch((e: unknown) => e);
|
|
323
376
|
|
|
@@ -14,12 +14,18 @@
|
|
|
14
14
|
import { create } from "@bufbuild/protobuf";
|
|
15
15
|
import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
16
16
|
import type { ConnectInput } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
ConnectInputSchema,
|
|
19
|
+
GetOAuthGrantStatusInputSchema,
|
|
20
|
+
} from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
|
|
18
21
|
import type { DiscoveredCapabilities } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/status_pb";
|
|
19
22
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
20
23
|
import type { Stigmer } from "@stigmer/sdk";
|
|
21
|
-
import {
|
|
22
|
-
|
|
24
|
+
import {
|
|
25
|
+
connectAndWait,
|
|
26
|
+
ConnectStillRunningError,
|
|
27
|
+
CONNECT_SETTLE_BOUND_MS,
|
|
28
|
+
} from "@stigmer/sdk";
|
|
23
29
|
import { CliExitError, ExitCode, UsageError } from "../../errors/index.js";
|
|
24
30
|
import { defaultRegistry } from "../../registry/index.js";
|
|
25
31
|
import { PlaceholderResolutionError } from "../mcp/placeholder-resolver.js";
|
|
@@ -42,8 +48,10 @@ export interface ConnectOptions {
|
|
|
42
48
|
readonly pushTimeoutMs?: number;
|
|
43
49
|
readonly dryRun: boolean;
|
|
44
50
|
readonly envOverrides: readonly string[];
|
|
45
|
-
/**
|
|
46
|
-
readonly
|
|
51
|
+
/** The web console origin for the OAuth flow (resolveConsoleURL). */
|
|
52
|
+
readonly consoleURL: string;
|
|
53
|
+
/** Probe the console before opening the browser (local daemon only). */
|
|
54
|
+
readonly probeLocalConsole: boolean;
|
|
47
55
|
/** Whether an interactive terminal is available to run the OAuth flow. */
|
|
48
56
|
readonly interactive: boolean;
|
|
49
57
|
}
|
|
@@ -63,23 +71,34 @@ export interface ConnectResult {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/** Connect to an MCP server and discover its capabilities (push or dry-run). */
|
|
66
|
-
export async function connectMcpServer(
|
|
74
|
+
export async function connectMcpServer(
|
|
75
|
+
client: Stigmer,
|
|
76
|
+
opts: ConnectOptions,
|
|
77
|
+
): Promise<ConnectResult> {
|
|
67
78
|
const server = await resolveMcpServer(client, opts.reference, opts.org);
|
|
68
79
|
|
|
69
80
|
if (opts.dryRun) {
|
|
70
|
-
if (server.spec === undefined)
|
|
81
|
+
if (server.spec === undefined)
|
|
82
|
+
throw new UsageError(
|
|
83
|
+
"MCP server has no spec; cannot discover capabilities",
|
|
84
|
+
);
|
|
71
85
|
// An oauth_only endpoint rejects static tokens, and the OAuth token lives in
|
|
72
86
|
// the backend's managed environment — never on the caller's machine. So local
|
|
73
87
|
// discovery cannot authenticate it; say so plainly instead of failing on a 401.
|
|
74
88
|
if (isOAuthOnly(server)) throw oauthOnlyDryRunError(server, opts.reference);
|
|
75
89
|
try {
|
|
76
|
-
const capabilities = await localDiscover(
|
|
90
|
+
const capabilities = await localDiscover(
|
|
91
|
+
server.spec,
|
|
92
|
+
opts.envOverrides,
|
|
93
|
+
opts.timeoutMs,
|
|
94
|
+
);
|
|
77
95
|
return { server, capabilities, updated: undefined };
|
|
78
96
|
} catch (err) {
|
|
79
97
|
// A ${VAR} placeholder that could not be resolved is a configuration
|
|
80
98
|
// problem, not a discovery failure — surface it as actionable guidance
|
|
81
99
|
// instead of a raw resolver error or a cryptic subprocess crash.
|
|
82
|
-
if (err instanceof PlaceholderResolutionError)
|
|
100
|
+
if (err instanceof PlaceholderResolutionError)
|
|
101
|
+
throw unresolvedEnvError(server, err);
|
|
83
102
|
throw err;
|
|
84
103
|
}
|
|
85
104
|
}
|
|
@@ -150,18 +169,29 @@ async function serverSideConnect(
|
|
|
150
169
|
|
|
151
170
|
// Resolve a reference (id, org/slug, or bare slug) to an McpServer. Mirrors Go's
|
|
152
171
|
// GetFromBackend.
|
|
153
|
-
async function resolveMcpServer(
|
|
172
|
+
async function resolveMcpServer(
|
|
173
|
+
client: Stigmer,
|
|
174
|
+
reference: string,
|
|
175
|
+
org: string,
|
|
176
|
+
): Promise<McpServer> {
|
|
154
177
|
const idPrefix = defaultRegistry().getByAlias("mcp-server")?.idPrefix ?? "";
|
|
155
178
|
const parsed = parseReference(reference, org, idPrefix);
|
|
156
179
|
if (parsed.kind === "id") return client.mcpServer.get(parsed.id);
|
|
157
|
-
return client.mcpServer.getByReference({
|
|
180
|
+
return client.mcpServer.getByReference({
|
|
181
|
+
org: parsed.org,
|
|
182
|
+
slug: parsed.slug,
|
|
183
|
+
});
|
|
158
184
|
}
|
|
159
185
|
|
|
160
186
|
// Ensure an OAuth grant exists before connecting an auth-configured server that
|
|
161
187
|
// was given no --env credentials. On an interactive terminal, run the browser
|
|
162
188
|
// flow and wait for the grant; otherwise stop with actionable guidance so
|
|
163
189
|
// scripted callers get a clean, stable failure instead of a 5-minute block.
|
|
164
|
-
async function ensureOAuthSatisfied(
|
|
190
|
+
async function ensureOAuthSatisfied(
|
|
191
|
+
client: Stigmer,
|
|
192
|
+
server: McpServer,
|
|
193
|
+
opts: ConnectOptions,
|
|
194
|
+
): Promise<void> {
|
|
165
195
|
if (!oauthRequired(server)) return;
|
|
166
196
|
|
|
167
197
|
const oauthOnly = isOAuthOnly(server);
|
|
@@ -183,18 +213,32 @@ async function ensureOAuthSatisfied(client: Stigmer, server: McpServer, opts: Co
|
|
|
183
213
|
);
|
|
184
214
|
if (status.connected) return;
|
|
185
215
|
|
|
186
|
-
if (!opts.interactive)
|
|
216
|
+
if (!opts.interactive)
|
|
217
|
+
throw oauthGuidanceError(server, opts.reference, oauthOnly);
|
|
187
218
|
|
|
188
219
|
const { runOAuthFlow } = await import("./oauth.js");
|
|
189
|
-
await runOAuthFlow({
|
|
220
|
+
await runOAuthFlow({
|
|
221
|
+
client,
|
|
222
|
+
server,
|
|
223
|
+
org: opts.org,
|
|
224
|
+
consoleURL: opts.consoleURL,
|
|
225
|
+
probeLocalConsole: opts.probeLocalConsole,
|
|
226
|
+
});
|
|
190
227
|
}
|
|
191
228
|
|
|
192
229
|
// Turn a strict-resolution failure into actionable guidance. A declared-but-unset
|
|
193
230
|
// variable is the user's to provide; an undeclared one is a bug in the server
|
|
194
231
|
// definition. Both are far clearer than the cryptic subprocess crash (ENOENT on a
|
|
195
232
|
// literal "${VAR}" path) that motivated issue #141.
|
|
196
|
-
function unresolvedEnvError(
|
|
197
|
-
|
|
233
|
+
function unresolvedEnvError(
|
|
234
|
+
server: McpServer,
|
|
235
|
+
err: PlaceholderResolutionError,
|
|
236
|
+
): UsageError {
|
|
237
|
+
const slug =
|
|
238
|
+
server.metadata?.slug ??
|
|
239
|
+
server.metadata?.name ??
|
|
240
|
+
server.metadata?.id ??
|
|
241
|
+
"this server";
|
|
198
242
|
const decl = server.spec?.env?.[err.variableName];
|
|
199
243
|
if (decl) {
|
|
200
244
|
const hint = decl.description !== "" ? ` (${decl.description})` : "";
|
|
@@ -210,7 +254,11 @@ function unresolvedEnvError(server: McpServer, err: PlaceholderResolutionError):
|
|
|
210
254
|
);
|
|
211
255
|
}
|
|
212
256
|
|
|
213
|
-
function oauthGuidanceError(
|
|
257
|
+
function oauthGuidanceError(
|
|
258
|
+
server: McpServer,
|
|
259
|
+
reference: string,
|
|
260
|
+
oauthOnly: boolean,
|
|
261
|
+
): UsageError {
|
|
214
262
|
const slug = server.metadata?.slug ?? server.metadata?.name ?? reference;
|
|
215
263
|
const choices = [
|
|
216
264
|
" - Re-run this command in an interactive terminal to complete OAuth in your browser",
|
|
@@ -218,7 +266,9 @@ function oauthGuidanceError(server: McpServer, reference: string, oauthOnly: boo
|
|
|
218
266
|
// Only offer the manual-token route for servers that actually accept one.
|
|
219
267
|
// Suggesting it for an oauth_only endpoint would send the user down a dead end.
|
|
220
268
|
if (!oauthOnly) {
|
|
221
|
-
choices.push(
|
|
269
|
+
choices.push(
|
|
270
|
+
` - Provide credentials directly: stigmer connect mcp-server ${slug} --env TOKEN=...`,
|
|
271
|
+
);
|
|
222
272
|
}
|
|
223
273
|
return new UsageError(
|
|
224
274
|
`MCP server '${slug}' requires OAuth authentication, which needs an interactive terminal.\n\n` +
|
|
@@ -241,7 +291,10 @@ function oauthOnlyEnvError(server: McpServer, reference: string): UsageError {
|
|
|
241
291
|
// --dry-run discovers locally, but an oauth_only endpoint needs an OAuth token
|
|
242
292
|
// that only the backend can obtain and store — there is nothing valid to send
|
|
243
293
|
// from the caller's machine. Say so plainly instead of attempting a doomed 401.
|
|
244
|
-
function oauthOnlyDryRunError(
|
|
294
|
+
function oauthOnlyDryRunError(
|
|
295
|
+
server: McpServer,
|
|
296
|
+
reference: string,
|
|
297
|
+
): UsageError {
|
|
245
298
|
const slug = server.metadata?.slug ?? server.metadata?.name ?? reference;
|
|
246
299
|
return new UsageError(
|
|
247
300
|
`MCP server '${slug}' requires OAuth, so --dry-run cannot discover it locally — its endpoint only accepts an OAuth token that the connected backend obtains for you.\n` +
|
|
@@ -3,7 +3,12 @@ import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1
|
|
|
3
3
|
import type { Stigmer } from "@stigmer/sdk";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { UsageError } from "../../errors/index.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
browserCommand,
|
|
8
|
+
runOAuthFlow,
|
|
9
|
+
waitForOAuthGrant,
|
|
10
|
+
type OAuthFlowDeps,
|
|
11
|
+
} from "./oauth.js";
|
|
7
12
|
|
|
8
13
|
const server = create(McpServerSchema, {
|
|
9
14
|
metadata: { id: "mcp_1", slug: "github", name: "GitHub" },
|
|
@@ -11,7 +16,10 @@ const server = create(McpServerSchema, {
|
|
|
11
16
|
|
|
12
17
|
// A fake client whose getOAuthGrantStatus reports `connected` once the call
|
|
13
18
|
// count reaches `connectOnCall` (1 = first poll). Records the call count.
|
|
14
|
-
function fakeClient(connectOnCall: number): {
|
|
19
|
+
function fakeClient(connectOnCall: number): {
|
|
20
|
+
client: Stigmer;
|
|
21
|
+
calls: () => number;
|
|
22
|
+
} {
|
|
15
23
|
let calls = 0;
|
|
16
24
|
const client = {
|
|
17
25
|
mcpServer: {
|
|
@@ -28,9 +36,18 @@ const noopSleep = async (): Promise<void> => {};
|
|
|
28
36
|
|
|
29
37
|
describe("browserCommand", () => {
|
|
30
38
|
it("maps each supported platform to its opener", () => {
|
|
31
|
-
expect(browserCommand("darwin", "https://x")).toEqual([
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
expect(browserCommand("darwin", "https://x")).toEqual([
|
|
40
|
+
"open",
|
|
41
|
+
["https://x"],
|
|
42
|
+
]);
|
|
43
|
+
expect(browserCommand("linux", "https://x")).toEqual([
|
|
44
|
+
"xdg-open",
|
|
45
|
+
["https://x"],
|
|
46
|
+
]);
|
|
47
|
+
expect(browserCommand("win32", "https://x")).toEqual([
|
|
48
|
+
"rundll32",
|
|
49
|
+
["url.dll,FileProtocolHandler", "https://x"],
|
|
50
|
+
]);
|
|
34
51
|
});
|
|
35
52
|
|
|
36
53
|
it("returns no command for unsupported platforms", () => {
|
|
@@ -45,7 +62,8 @@ describe("waitForOAuthGrant", () => {
|
|
|
45
62
|
client,
|
|
46
63
|
server,
|
|
47
64
|
org: "acme",
|
|
48
|
-
|
|
65
|
+
consoleURL: "https://app.stigmer.ai",
|
|
66
|
+
probeLocalConsole: false,
|
|
49
67
|
now: () => 0,
|
|
50
68
|
sleep: noopSleep,
|
|
51
69
|
log: () => {},
|
|
@@ -63,7 +81,8 @@ describe("waitForOAuthGrant", () => {
|
|
|
63
81
|
client,
|
|
64
82
|
server,
|
|
65
83
|
org: "acme",
|
|
66
|
-
|
|
84
|
+
consoleURL: "https://app.stigmer.ai",
|
|
85
|
+
probeLocalConsole: false,
|
|
67
86
|
now: () => clock.shift() ?? 10 * 60 * 1000,
|
|
68
87
|
sleep: noopSleep,
|
|
69
88
|
log: () => {},
|
|
@@ -85,13 +104,16 @@ describe("runOAuthFlow", () => {
|
|
|
85
104
|
client,
|
|
86
105
|
server,
|
|
87
106
|
org: "acme",
|
|
88
|
-
|
|
107
|
+
consoleURL: "https://app.stigmer.ai",
|
|
108
|
+
probeLocalConsole: false,
|
|
89
109
|
openBrowser,
|
|
90
110
|
now: () => 0,
|
|
91
111
|
sleep: noopSleep,
|
|
92
112
|
log: () => {},
|
|
93
113
|
});
|
|
94
|
-
expect(openBrowser).toHaveBeenCalledWith(
|
|
114
|
+
expect(openBrowser).toHaveBeenCalledWith(
|
|
115
|
+
"https://app.stigmer.ai/acme/mcp-servers/github",
|
|
116
|
+
);
|
|
95
117
|
});
|
|
96
118
|
|
|
97
119
|
it("aborts before opening the browser when the local console is unreachable", async () => {
|
|
@@ -102,7 +124,8 @@ describe("runOAuthFlow", () => {
|
|
|
102
124
|
client,
|
|
103
125
|
server,
|
|
104
126
|
org: "acme",
|
|
105
|
-
|
|
127
|
+
consoleURL: "http://localhost:7234",
|
|
128
|
+
probeLocalConsole: true,
|
|
106
129
|
probeConsole: async () => false,
|
|
107
130
|
openBrowser,
|
|
108
131
|
now: () => 0,
|
|
@@ -120,13 +143,16 @@ describe("runOAuthFlow", () => {
|
|
|
120
143
|
client,
|
|
121
144
|
server,
|
|
122
145
|
org: "acme",
|
|
123
|
-
|
|
146
|
+
consoleURL: "http://localhost:7234",
|
|
147
|
+
probeLocalConsole: true,
|
|
124
148
|
probeConsole: async () => true,
|
|
125
149
|
openBrowser,
|
|
126
150
|
now: () => 0,
|
|
127
151
|
sleep: noopSleep,
|
|
128
152
|
log: () => {},
|
|
129
153
|
});
|
|
130
|
-
expect(openBrowser).toHaveBeenCalledWith(
|
|
154
|
+
expect(openBrowser).toHaveBeenCalledWith(
|
|
155
|
+
"http://localhost:7234/acme/mcp-servers/github",
|
|
156
|
+
);
|
|
131
157
|
});
|
|
132
158
|
});
|
|
@@ -15,8 +15,6 @@ import { create } from "@bufbuild/protobuf";
|
|
|
15
15
|
import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
16
16
|
import { GetOAuthGrantStatusInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
|
|
17
17
|
import type { Stigmer } from "@stigmer/sdk";
|
|
18
|
-
import type { BackendType } from "../../config/config.js";
|
|
19
|
-
import { resolveConsoleURL } from "../../config/index.js";
|
|
20
18
|
import { UsageError } from "../../errors/index.js";
|
|
21
19
|
|
|
22
20
|
const POLL_INTERVAL_MS = 3000;
|
|
@@ -28,7 +26,11 @@ export interface OAuthFlowDeps {
|
|
|
28
26
|
readonly client: Stigmer;
|
|
29
27
|
readonly server: McpServer;
|
|
30
28
|
readonly org: string;
|
|
31
|
-
|
|
29
|
+
/** The web console origin (resolveConsoleURL over the caller's config). */
|
|
30
|
+
readonly consoleURL: string;
|
|
31
|
+
/** Probe the console before opening the browser (the local daemon's
|
|
32
|
+
* console may simply not be running; remote consoles answer or 404). */
|
|
33
|
+
readonly probeLocalConsole: boolean;
|
|
32
34
|
/** Opens the page URL in the user's browser. Defaults to the OS opener. */
|
|
33
35
|
readonly openBrowser?: (url: string) => Promise<void>;
|
|
34
36
|
/** Probes whether the local web console is reachable. Defaults to a 2s GET. */
|
|
@@ -48,11 +50,12 @@ export interface OAuthFlowDeps {
|
|
|
48
50
|
*/
|
|
49
51
|
export async function runOAuthFlow(deps: OAuthFlowDeps): Promise<void> {
|
|
50
52
|
const log = deps.log ?? stderrLog;
|
|
51
|
-
const consoleURL =
|
|
53
|
+
const consoleURL = deps.consoleURL;
|
|
52
54
|
|
|
53
|
-
if (deps.
|
|
55
|
+
if (deps.probeLocalConsole) {
|
|
54
56
|
const probe = deps.probeConsole ?? probeWebConsole;
|
|
55
|
-
if (!(await probe(consoleURL)))
|
|
57
|
+
if (!(await probe(consoleURL)))
|
|
58
|
+
throw consoleUnavailableError(deps.server.metadata?.slug ?? "<slug>");
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const slug = deps.server.metadata?.slug ?? "";
|
|
@@ -67,7 +70,9 @@ export async function runOAuthFlow(deps: OAuthFlowDeps): Promise<void> {
|
|
|
67
70
|
try {
|
|
68
71
|
await open(pageURL);
|
|
69
72
|
} catch {
|
|
70
|
-
log(
|
|
73
|
+
log(
|
|
74
|
+
" Could not open browser automatically. Please open the URL above in your browser.",
|
|
75
|
+
);
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
await waitForOAuthGrant(deps);
|
|
@@ -89,7 +94,9 @@ export async function waitForOAuthGrant(deps: OAuthFlowDeps): Promise<void> {
|
|
|
89
94
|
for (;;) {
|
|
90
95
|
await sleep(POLL_INTERVAL_MS);
|
|
91
96
|
if (now() >= deadline) {
|
|
92
|
-
throw new UsageError(
|
|
97
|
+
throw new UsageError(
|
|
98
|
+
"timed out waiting for OAuth connection — please try again",
|
|
99
|
+
);
|
|
93
100
|
}
|
|
94
101
|
if (await checkOAuthGrant(deps.client, serverId, deps.org)) {
|
|
95
102
|
log("OAuth connection established.\n");
|
|
@@ -99,7 +106,11 @@ export async function waitForOAuthGrant(deps: OAuthFlowDeps): Promise<void> {
|
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
/** Query the backend for an existing/just-created OAuth grant. */
|
|
102
|
-
export async function checkOAuthGrant(
|
|
109
|
+
export async function checkOAuthGrant(
|
|
110
|
+
client: Stigmer,
|
|
111
|
+
mcpServerId: string,
|
|
112
|
+
org: string,
|
|
113
|
+
): Promise<boolean> {
|
|
103
114
|
const status = await client.mcpServer.getOAuthGrantStatus(
|
|
104
115
|
create(GetOAuthGrantStatusInputSchema, { resourceId: mcpServerId, org }),
|
|
105
116
|
);
|
|
@@ -110,7 +121,9 @@ export async function checkOAuthGrant(client: Stigmer, mcpServerId: string, org:
|
|
|
110
121
|
// an error status) means it's serving; a network/timeout failure means it isn't.
|
|
111
122
|
async function probeWebConsole(url: string): Promise<boolean> {
|
|
112
123
|
try {
|
|
113
|
-
const response = await fetch(url, {
|
|
124
|
+
const response = await fetch(url, {
|
|
125
|
+
signal: AbortSignal.timeout(CONSOLE_PROBE_TIMEOUT_MS),
|
|
126
|
+
});
|
|
114
127
|
// Drain the body so the socket can close promptly.
|
|
115
128
|
await response.body?.cancel();
|
|
116
129
|
return true;
|
|
@@ -153,7 +166,10 @@ function openInBrowser(url: string): Promise<void> {
|
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
/** The OS-specific command + args to open a URL. Exported for tests. */
|
|
156
|
-
export function browserCommand(
|
|
169
|
+
export function browserCommand(
|
|
170
|
+
platform: NodeJS.Platform,
|
|
171
|
+
url: string,
|
|
172
|
+
): [string | undefined, string[]] {
|
|
157
173
|
switch (platform) {
|
|
158
174
|
case "darwin":
|
|
159
175
|
return ["open", [url]];
|