@amerged/ohmyhost-mcp 0.1.18 → 0.1.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amerged/ohmyhost-mcp",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -101,12 +101,14 @@ import {
101
101
  } from "@ohmyhost/sdk-ts";
102
102
 
103
103
  import type {
104
+ IdentityReport,
104
105
  LocalMcpProductClient,
105
106
  WorkspaceReport,
106
107
  WorkspaceSelectionReport,
107
108
  } from "./local-server.js";
108
109
  import {
109
110
  createAuthenticatedProductTransport,
111
+ resolveProductCliEnvironment,
110
112
  prepareUserTokenFile,
111
113
  ProductApiAuthenticationError,
112
114
  parseDevAccessTicket,
@@ -119,11 +121,13 @@ import {
119
121
  parseAccountProfile,
120
122
  parseCurrentIdentity,
121
123
  chooseWorkspace,
124
+ publicProfile,
122
125
  publicWorkspace,
123
126
  selectWorkspace,
124
- selectedWorkspaceId,
125
127
  InteractiveSessionRequiredError,
126
128
  type AuthenticatedProductApiOverrides,
129
+ type EffectiveContext,
130
+ type LocalProfile,
127
131
  type WorkspaceSession,
128
132
  } from "@ohmyhost/product-cli";
129
133
 
@@ -134,17 +138,26 @@ export class OperationEventsUnavailableError extends Error {
134
138
  }
135
139
  }
136
140
 
141
+ /**
142
+ * One client per tool call, for the login that call asked for. The process binding and the call's
143
+ * own arguments choose it, so another agent's choice never changes which account a call uses.
144
+ */
137
145
  export async function createAuthenticatedLocalMcpClient(
138
146
  signal: AbortSignal,
139
- overrides: Omit<AuthenticatedProductApiOverrides, "productApiOrigin"> = {},
147
+ overrides: Omit<AuthenticatedProductApiOverrides, "productApiOrigin" | "selector"> = {},
140
148
  ): Promise<LocalMcpProductClient> {
141
149
  try {
142
- const transport = await createAuthenticatedProductTransport(signal, overrides);
150
+ const transport = await createAuthenticatedProductTransport(signal, {
151
+ ...overrides,
152
+ selector: "mcp",
153
+ });
143
154
  return new GeneratedSdkLocalMcpClient(
144
155
  transport.baseUrl,
145
156
  transport.accessToken,
146
157
  transport.fetch,
147
158
  transport.workspace,
159
+ transport.context,
160
+ resolveProductCliEnvironment(overrides.environment).commandPrefix,
148
161
  );
149
162
  } catch (error) {
150
163
  if (!(error instanceof ProductApiAuthenticationError)) throw error;
@@ -160,9 +173,13 @@ export async function createAuthenticatedLocalMcpClient(
160
173
  }
161
174
 
162
175
  export class GeneratedSdkLocalMcpClient implements LocalMcpProductClient {
163
- readonly #client: OhMyHostClient;
176
+ #client: OhMyHostClient;
164
177
 
165
- readonly #workspace: WorkspaceSession | undefined;
178
+ #workspace: WorkspaceSession | undefined;
179
+
180
+ readonly #context: EffectiveContext;
181
+
182
+ readonly #commandPrefix: string;
166
183
 
167
184
  readonly #rescope: (accessToken: string) => OhMyHostClient;
168
185
 
@@ -171,10 +188,50 @@ export class GeneratedSdkLocalMcpClient implements LocalMcpProductClient {
171
188
  accessToken: string,
172
189
  fetch: typeof globalThis.fetch,
173
190
  workspace?: WorkspaceSession,
191
+ context?: EffectiveContext,
192
+ commandPrefix = "OHMYHOST_ENVIRONMENT=production ohmyhost",
174
193
  ) {
175
194
  this.#rescope = (token) => createClient({ auth: token, baseUrl, fetch, throwOnError: true });
176
195
  this.#client = this.#rescope(accessToken);
177
196
  this.#workspace = workspace;
197
+ this.#context = context ?? {
198
+ credential: workspace === undefined ? "environment_token" : "profile",
199
+ profile: workspace?.profile ?? null,
200
+ };
201
+ this.#commandPrefix = commandPrefix;
202
+ }
203
+
204
+ public actingAs(): IdentityReport["context"] {
205
+ return this.#contextReport();
206
+ }
207
+
208
+ /** Which account this call acts as, without any credential. */
209
+ #contextReport(): IdentityReport["context"] {
210
+ const profile = this.#workspace?.profile ?? this.#context.profile;
211
+ return {
212
+ credential: this.#context.credential,
213
+ profile: profile === null ? null : publicProfile(profile),
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Bind this call's login, which has no organization yet, and continue with its new credential.
219
+ * A login in another organization keeps it; the provider verifies the account first.
220
+ */
221
+ async #bind(
222
+ organization: Parameters<typeof selectWorkspace>[1],
223
+ signal: AbortSignal,
224
+ ): Promise<LocalProfile> {
225
+ const session = this.#interactiveSession();
226
+ const bound = await selectWorkspace(session, organization, signal, (token) =>
227
+ getCurrentIdentity({}, { client: this.#rescope(token) }),
228
+ );
229
+ const store = session.profiles.credential(bound);
230
+ const credential = await store.read();
231
+ if (credential === undefined) throw new InteractiveSessionRequiredError();
232
+ this.#workspace = { ...session, store, profile: bound };
233
+ this.#client = this.#rescope(credential.accessToken);
234
+ return bound;
178
235
  }
179
236
 
180
237
  public async changeDatabaseCompute(input: {
@@ -275,20 +332,20 @@ export class GeneratedSdkLocalMcpClient implements LocalMcpProductClient {
275
332
  { client: this.#client },
276
333
  );
277
334
  }
278
- public async getCurrentIdentity(signal: AbortSignal) {
335
+ public async getCurrentIdentity(signal: AbortSignal): Promise<IdentityReport> {
279
336
  const identity = await getCurrentIdentity({}, { client: this.#client });
280
337
  // A session signed in before a workspace could be selected carries none, and then every
281
338
  // project call answers with an empty page that reads like an empty account.
282
339
  if (this.#workspace === undefined || parseCurrentIdentity(identity).organization_ids.length > 0)
283
- return identity;
340
+ return { ...identity, context: this.#contextReport() };
284
341
  const selection = chooseWorkspace(await this.#readWorkspaces(), undefined);
285
- if (selection.outcome !== "selected") return identity;
286
- await selectWorkspace(this.#workspace, selection.organization, signal);
342
+ if (selection.outcome !== "selected") return { ...identity, context: this.#contextReport() };
287
343
  // The workspace lives in the access token, so the repaired identity needs the new one.
288
- const scoped = (await this.#workspace.store.read())?.accessToken;
289
- return scoped === undefined
290
- ? identity
291
- : getCurrentIdentity({}, { client: this.#rescope(scoped) });
344
+ await this.#bind(selection.organization, signal);
345
+ return {
346
+ ...(await getCurrentIdentity({}, { client: this.#client })),
347
+ context: this.#contextReport(),
348
+ };
292
349
  }
293
350
  public async changeProjectHandle(input: {
294
351
  projectId: string;
@@ -466,6 +523,16 @@ export class GeneratedSdkLocalMcpClient implements LocalMcpProductClient {
466
523
  },
467
524
  { client: this.#client },
468
525
  );
526
+ const current = this.#interactiveSession().profile;
527
+ // A login in another organization keeps it, so no other agent using it moves along; the new
528
+ // workspace gets its own login instead.
529
+ if (current.organizationId !== null && current.organizationId !== created.id)
530
+ return {
531
+ organization: { id: created.id, name: created.name },
532
+ selected: current.organizationId,
533
+ profile: publicProfile(current),
534
+ next_action: `Run '${this.#commandPrefix} login --organization ${created.id} --user ${current.userId} --json' to add a login for the new workspace as the same user, then pass its profile_name.`,
535
+ };
469
536
  // A created workspace only becomes usable once the access token carries it.
470
537
  return this.selectOrganization(created.id, signal);
471
538
  }
@@ -474,7 +541,8 @@ export class GeneratedSdkLocalMcpClient implements LocalMcpProductClient {
474
541
  const session = this.#interactiveSession();
475
542
  return {
476
543
  organizations: (await this.#readWorkspaces()).map(publicWorkspace),
477
- selected: await selectedWorkspaceId(session),
544
+ selected: session.profile.organizationId,
545
+ profile: publicProfile(session.profile),
478
546
  };
479
547
  }
480
548
 
@@ -482,20 +550,21 @@ export class GeneratedSdkLocalMcpClient implements LocalMcpProductClient {
482
550
  organizationId: string,
483
551
  signal: AbortSignal,
484
552
  ): Promise<WorkspaceSelectionReport> {
485
- const session = this.#interactiveSession();
553
+ this.#interactiveSession();
486
554
  const selection = chooseWorkspace(await this.#readWorkspaces(), organizationId);
487
555
  if (selection.outcome !== "selected")
488
556
  throw new Error(`ORGANIZATION_NOT_FOUND:${organizationId}`);
489
- await selectWorkspace(session, selection.organization, signal);
557
+ const profile = await this.#bind(selection.organization, signal);
490
558
  return {
491
559
  organization: publicWorkspace(selection.organization),
492
560
  selected: selection.organization.id,
561
+ profile: publicProfile(profile),
493
562
  };
494
563
  }
495
564
 
496
565
  async #readWorkspaces() {
497
566
  this.#interactiveSession();
498
- return parseAccountProfile(await getAccountProfile({ client: this.#client }));
567
+ return parseAccountProfile(await getAccountProfile({ client: this.#client })).organizations;
499
568
  }
500
569
 
501
570
  #interactiveSession(): WorkspaceSession {