@amerged/ohmyhost-mcp 0.1.19 → 0.1.21

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.
@@ -9,9 +9,16 @@ import {
9
9
  } from "@modelcontextprotocol/server";
10
10
  import { z } from "zod";
11
11
  import {
12
+ ACCOUNT_SENSITIVE_PROBLEMS,
13
+ CredentialStoreUnavailableError,
14
+ isProfileUserId,
15
+ parseCurrentIdentity,
16
+ profileLimitAction,
17
+ ProfileSelectionError,
12
18
  resolveProductCliEnvironment,
13
19
  parseSafeProblem,
14
20
  type ProductCliEnvironment,
21
+ type ProfileRequestInput,
15
22
  } from "@ohmyhost/product-cli";
16
23
  import { createOhmyhostMcpServer } from "./index.js";
17
24
 
@@ -63,6 +70,12 @@ export interface LocalMcpProductClient {
63
70
  projectId: string;
64
71
  idempotencyKey: string;
65
72
  }): Promise<import("@ohmyhost/sdk-ts").DevAccessState>;
73
+ getPoweredByFlag(projectId: string): Promise<import("@ohmyhost/sdk-ts").PoweredByFlag>;
74
+ setPoweredByFlag(input: {
75
+ projectId: string;
76
+ enabled: boolean;
77
+ idempotencyKey: string;
78
+ }): Promise<import("@ohmyhost/sdk-ts").PoweredByFlag>;
66
79
  revokeDevShareLink(input: {
67
80
  projectId: string;
68
81
  idempotencyKey: string;
@@ -149,6 +162,9 @@ export interface LocalMcpProductClient {
149
162
  getOrganizationAccount(
150
163
  organizationId: string,
151
164
  ): Promise<import("@ohmyhost/sdk-ts").OrganizationAccount>;
165
+ getOrganizationReferral(
166
+ organizationId: string,
167
+ ): Promise<import("@ohmyhost/sdk-ts").OrganizationReferral>;
152
168
  getOrganizationCredits(
153
169
  organizationId: string,
154
170
  ): Promise<import("@ohmyhost/sdk-ts").OrganizationCredits>;
@@ -174,7 +190,9 @@ export interface LocalMcpProductClient {
174
190
  organizationId: string,
175
191
  signal: AbortSignal,
176
192
  ): Promise<WorkspaceSelectionReport>;
177
- getCurrentIdentity(signal: AbortSignal): Promise<CurrentIdentity>;
193
+ getCurrentIdentity(signal: AbortSignal): Promise<IdentityReport>;
194
+ /** The account this client acts as, for errors whose meaning depends on it. */
195
+ actingAs?(): IdentityReport["context"];
178
196
  listProjects(input: { readonly cursor?: string; readonly limit?: number }): Promise<ProjectPage>;
179
197
  submitFeedback(input: {
180
198
  report: import("@ohmyhost/sdk-ts").FeedbackSubmission;
@@ -261,21 +279,53 @@ export interface PublicWorkspaceSummary {
261
279
  readonly name: string;
262
280
  }
263
281
 
264
- /** The workspaces of the signed-in user and the one this machine is currently scoped to. */
282
+ /** One saved login as `profile_list` shows it; it never carries a credential. */
283
+ export interface PublicProfileSummary {
284
+ readonly name: string;
285
+ readonly user_id: string;
286
+ readonly organization_id: string | null;
287
+ readonly organization_name: string | null;
288
+ }
289
+
290
+ /** The identity a call acted as, and which saved login or OHMYHOST_TOKEN supplied it. */
291
+ export type IdentityReport = CurrentIdentity & {
292
+ readonly context: {
293
+ readonly credential: "profile" | "environment_token";
294
+ readonly profile: PublicProfileSummary | null;
295
+ };
296
+ };
297
+
298
+ /** The workspaces of the signed-in user and the one the chosen login is scoped to. */
265
299
  export interface WorkspaceReport {
266
300
  readonly organizations: readonly PublicWorkspaceSummary[];
267
301
  readonly selected: string | null;
302
+ readonly profile: PublicProfileSummary;
268
303
  }
269
304
 
270
305
  /** One workspace that was just created or selected, with the resulting scope. */
271
306
  export interface WorkspaceSelectionReport {
272
307
  readonly organization: PublicWorkspaceSummary;
273
308
  readonly selected: string;
309
+ readonly profile: PublicProfileSummary;
310
+ readonly next_action?: string;
311
+ }
312
+
313
+ /** The saved logins of this computer and how this MCP process chooses among them. */
314
+ export interface ProfileListReport {
315
+ readonly profiles: readonly PublicProfileSummary[];
316
+ readonly bound: string | null;
317
+ readonly environment_token: boolean;
274
318
  }
275
319
 
276
320
  export interface LocalOhmyhostMcpDependencies {
277
321
  readonly environment?: ProductCliEnvironment;
278
- authenticatedClient(signal: AbortSignal): Promise<LocalMcpProductClient>;
322
+ /** One client for one call; the request names the login and organization that call is about. */
323
+ authenticatedClient(
324
+ signal: AbortSignal,
325
+ request?: ProfileRequestInput,
326
+ ): Promise<LocalMcpProductClient>;
327
+ /** Saved logins, read without choosing one; absent where no local store exists. */
328
+ listProfiles?(signal: AbortSignal): Promise<ProfileListReport>;
279
329
  }
280
330
 
281
331
  interface ProjectCreateInput {
@@ -351,6 +401,14 @@ interface ProjectDeleteInput {
351
401
  }
352
402
 
353
403
  const identifier = z.string().min(1).max(256);
404
+ /** Every product tool accepts the saved login to act as; it is an alias, never a credential. */
405
+ const profileName = z
406
+ .string()
407
+ .regex(/^[a-z0-9][a-z0-9_-]{0,62}$/u)
408
+ .optional()
409
+ .describe(
410
+ "Saved login to act as (profile_list shows them). Needed only when several logins could run this call; OHMYHOST_PROFILE of this server must match it.",
411
+ );
354
412
  const idempotencyKey = z.string().min(1).max(256);
355
413
  const commitSha = z.string().regex(/^[a-f0-9]{40}$/u);
356
414
  const domainName = z
@@ -657,6 +715,14 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
657
715
  z.object({ organization_id: identifier }),
658
716
  (client, input) => client.getOrganizationAccount(input.organization_id),
659
717
  );
718
+ registerProductTool(
719
+ server,
720
+ dependencies,
721
+ "referral_link_get",
722
+ "Read the workspace's referral link to share. A new user whose first workspace comes from it starts with a free Paid month and 1,000 credits; that workspace's first payment gives this workspace the same. Any member may read it.",
723
+ z.object({ organization_id: identifier }),
724
+ (client, input) => client.getOrganizationReferral(input.organization_id),
725
+ );
660
726
  registerProductTool(
661
727
  server,
662
728
  dependencies,
@@ -700,7 +766,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
700
766
  server,
701
767
  dependencies,
702
768
  "organization_create",
703
- `Create an organization owned by the signed-in user and select it for this machine. Signup is open. signup_source is optional attribution from a link's r parameter, never a secret; omit it when the customer arrived directly. The server decides any once-per-user bonus; do not promise credits for arbitrary sources. Repeat the same name/source/key after uncertainty. No second login is needed: selected names the workspace every later call uses, so continue with identity_get, github_status and github_connect if needed, then projects. An OHMYHOST_TOKEN process cannot do this; it needs ${commandPrefix} login --json.`,
769
+ `Create an organization owned by the signed-in user. Signup is open. signup_source is optional attribution from a link's r parameter, never a secret; omit it when the customer arrived directly. The server decides any once-per-user bonus; do not promise credits for arbitrary sources. Repeat the same name/source/key after uncertainty. A login that has no organization yet is bound to the new one, so selected names the workspace later calls use; a login already in another organization keeps it and next_action names the login for the new one. Continue with identity_get, github_status and github_connect if needed, then projects. An OHMYHOST_TOKEN process cannot do this; it needs ${commandPrefix} login --json.`,
704
770
  z.object({
705
771
  name: z.string().min(1).max(128),
706
772
  signup_source: z
@@ -724,7 +790,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
724
790
  server,
725
791
  dependencies,
726
792
  "organization_list",
727
- "List the workspaces the signed-in user belongs to and which one this machine currently uses. selected is null while none is chosen; every project and billing call then answers resource_not_found. Ask the customer which workspace to use instead of guessing, then call organization_use.",
793
+ "List the workspaces the chosen login's user belongs to and which one that login is scoped to. selected is null while none is chosen; every project and billing call then answers resource_not_found. Ask the customer which workspace to use instead of guessing, then call organization_use.",
728
794
  z.object({}),
729
795
  (client) => client.listOrganizations(),
730
796
  );
@@ -732,10 +798,32 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
732
798
  server,
733
799
  dependencies,
734
800
  "organization_use",
735
- `Select one workspace for this machine's stored login, so later calls act inside it. Read organization_list first, then github_status for the selected workspace and github_connect if needed; the identifier is the platform ULID, not the provider identifier. An OHMYHOST_TOKEN process cannot select anything and needs ${commandPrefix} login --json instead.`,
801
+ `Bind a saved login that has no organization yet to one workspace, so later calls act inside it. A login already in another organization keeps it (profile_organization_fixed): add a login for the other workspace as the same user with ${commandPrefix} login --organization ULID --user USER_ID --json. Read organization_list first, then github_status for the selected workspace and github_connect if needed; the identifier is the platform ULID, not the provider identifier. An OHMYHOST_TOKEN process cannot select anything and needs ${commandPrefix} login --json instead.`,
736
802
  z.object({ organization_id: identifier }),
737
803
  (client, input, signal) => client.selectOrganization(input.organization_id, signal),
738
804
  mutationAnnotations(),
805
+ (input) => ({
806
+ ...(input.profile_name === undefined ? {} : { name: input.profile_name }),
807
+ organizationId: input.organization_id,
808
+ bindOrganization: true,
809
+ }),
810
+ );
811
+
812
+ server.registerTool(
813
+ "profile_list",
814
+ {
815
+ description: `List the saved ohmyho.st logins on this computer: each has a name, a user and an organization, never a token. When the customer named a user or organization, pass the matching name as profile_name on later calls; with several logins and no profile_name a call answers profile_selection_required. With no match, the customer adds one with ${commandPrefix} login --organization ULID --user USER_ID --json, which saves nothing if the browser signs in as another account. bound is this server's OHMYHOST_PROFILE; environment_token means OHMYHOST_TOKEN replaces saved logins here.`,
816
+ inputSchema: z.object({}),
817
+ annotations: readAnnotations(),
818
+ },
819
+ (async (_input: unknown, context: { mcpReq: { signal: AbortSignal } }) => {
820
+ try {
821
+ if (dependencies.listProfiles === undefined) throw new CredentialStoreUnavailableError();
822
+ return toolResult(await dependencies.listProfiles(context.mcpReq.signal));
823
+ } catch (error) {
824
+ return toolError(error, dependencies);
825
+ }
826
+ }) as ToolCallback<z.ZodObject<Record<string, never>>>,
739
827
  );
740
828
 
741
829
  registerProductTool(
@@ -940,7 +1028,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
940
1028
  server,
941
1029
  dependencies,
942
1030
  "identity_get",
943
- "Get the current ohmyho.st customer/agent identity. This does not authenticate the hosted application's end users or provision a customer AuthKit tenant; application authentication is the customer's chosen integration.",
1031
+ "Get the ohmyho.st customer/agent identity this call acts as: user, organization and, in context, the saved login or OHMYHOST_TOKEN that supplied it. Compare it with the user and organization the customer named before any change. This does not authenticate the hosted application's end users or provision a customer AuthKit tenant; application authentication is the customer's chosen integration.",
944
1032
  z.object({}),
945
1033
  (client, _input, signal) => client.getCurrentIdentity(signal),
946
1034
  );
@@ -1092,6 +1180,28 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1092
1180
  }),
1093
1181
  destructiveAnnotations(),
1094
1182
  );
1183
+ registerProductTool(
1184
+ server,
1185
+ dependencies,
1186
+ "powered_by_flag_get",
1187
+ 'Read whether the production site shows the opt-in "Powered by ohmyho.st" flag.',
1188
+ z.object({ project_id: identifier }),
1189
+ (client, input) => client.getPoweredByFlag(input.project_id),
1190
+ );
1191
+ registerProductTool(
1192
+ server,
1193
+ dependencies,
1194
+ "powered_by_flag_set",
1195
+ 'Owner only, ask the human first: show or hide a small "Powered by ohmyho.st" flag on the right edge of the production site. While it is on, a Free workspace may connect its own domain to this project free of the domain fee, and each Paid period adds 250 credits. Hiding it is refused while a Free workspace\'s domain depends on it. Replay the same key and value after uncertainty to get the first answer without switching again; every new switch needs a new key.',
1196
+ z.object({ project_id: identifier, enabled: z.boolean(), idempotency_key: idempotencyKey }),
1197
+ (client, input) =>
1198
+ client.setPoweredByFlag({
1199
+ projectId: input.project_id,
1200
+ enabled: input.enabled,
1201
+ idempotencyKey: input.idempotency_key,
1202
+ }),
1203
+ mutationAnnotations(),
1204
+ );
1095
1205
  registerProductTool(
1096
1206
  server,
1097
1207
  dependencies,
@@ -1144,7 +1254,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1144
1254
  server,
1145
1255
  dependencies,
1146
1256
  "github_status",
1147
- "Read this workspace's GitHub connection. If not_connected or revoked, an Owner or Admin must run github_connect. A connected installation covers only repositories selected in its settings.",
1257
+ "Read this workspace's GitHub connection. If not_connected or revoked, an Owner or Admin must run github_connect. A connected installation covers only repositories selected in its settings. Each workspace has its own connection, so the same repository can be connected and linked in the workspaces of separate accounts.",
1148
1258
  z.object({ organization_id: identifier }),
1149
1259
  (client, input) => client.getGithubConnection(input.organization_id),
1150
1260
  );
@@ -1152,7 +1262,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1152
1262
  server,
1153
1263
  dependencies,
1154
1264
  "source_link",
1155
- "Link a repository covered by the workspace GitHub connection. Call github_status first and github_connect once if needed. This returns an operation without another browser consent and never starts a build. Wait for that operation before planning a deploy. If repository access is missing, update the installation using the returned settings action, then retry the same project and key. Never ask for an installation ID or provider token.",
1265
+ "Link a repository covered by the workspace GitHub connection. Call github_status first and github_connect once if needed. This returns an operation without another browser consent and never starts a build. Wait for that operation before planning a deploy. If repository access is missing, update the installation using the returned settings action, then retry the same project and key. Never ask for an installation ID or provider token. Errors name the account the call ran as (acting_as): resource_not_found means that account cannot see the project (use the login of the account that owns it), github_connection_required means that workspace has no GitHub connection yet, and repository_not_installed means its GitHub App installation does not cover the repository.",
1156
1266
  z.object({
1157
1267
  project_id: identifier,
1158
1268
  repository_owner: identifier,
@@ -1237,7 +1347,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1237
1347
  server,
1238
1348
  dependencies,
1239
1349
  "deployment_logs",
1240
- "List the newest normalized diagnostics of one deployment (build, control, runtime and function failures with catalog codes). A BUILD_FAILED item carries excerpt: the sanitized tail of the customer's own install/build output, newest lines last, with excerpt_truncated when earlier output was omitted. Read it after build_failed before changing source; scheduled runs are in function_runs_list, not here.",
1350
+ "List the newest normalized diagnostics of one deployment (build, control, runtime and function failures with catalog codes). A failed operation_get names the deployment as deployment_id. A BUILD_FAILED item carries excerpt: the sanitized tail of the customer's own install/build output, newest lines last, with excerpt_truncated when earlier output was omitted. A HEALTH_CHECK_FAILED item carries the probed route and status_code and, only for a project owner, excerpt: the head (at most 4096 characters, control characters removed, never a header) of what the app answered. The probe is a cookieless GET that follows no redirect, 5 s per try and up to four tries, so a 3xx or an empty 503 is unhealthy too. Read the item before changing source; scheduled runs are in function_runs_list, not here.",
1241
1351
  z.object({
1242
1352
  project_id: identifier,
1243
1353
  deployment_id: identifier,
@@ -1254,7 +1364,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1254
1364
  server,
1255
1365
  dependencies,
1256
1366
  "operation_get",
1257
- "Get durable operation status and current deployment progress/reconciliation guidance. Follow progress.phase and progress.suggested_action. waiting_for_mail requires mail_domain_status now for the current verification issue and DNS records; publishing means the build completed but runtime preparation/publication is unfinished. Poll this operation after progress.next_poll_after_seconds. A queued/running operation with reconciliation.state=required needs one confirmed operation_reconcile using the same operation; pending means poll this operation after 60 seconds. Never infer deployment success from a completed reconciliation attempt or start another build to recover it.",
1367
+ "Get durable operation status and current deployment progress/reconciliation guidance. Follow progress.phase and progress.suggested_action. waiting_for_mail requires mail_domain_status now for the current verification issue and DNS records; publishing means the build completed but runtime preparation/publication is unfinished. Poll this operation after progress.next_poll_after_seconds. A queued/running operation with reconciliation.state=required needs one confirmed operation_reconcile using the same operation; pending means poll this operation after 60 seconds. Never infer deployment success from a completed reconciliation attempt or start another build to recover it. A deploy, promotion or rollback names its deployment_id in every state; when state is failed, read that deployment with deployment_logs and follow error.suggested_action before changing source.",
1258
1368
  z.object({ operation_id: identifier }),
1259
1369
  (client, input) => client.getOperation(input.operation_id),
1260
1370
  );
@@ -1543,21 +1653,22 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1543
1653
  destructiveAnnotations(),
1544
1654
  );
1545
1655
 
1546
- server.registerTool(
1656
+ registerProductTool(
1657
+ server,
1658
+ dependencies,
1547
1659
  "secret_set_command",
1548
- {
1549
- description:
1550
- "Return the stdin-only CLI command for setting a secret; the value never enters MCP.",
1551
- inputSchema: z.object({
1552
- project_id: identifier,
1553
- environment_id: identifier,
1554
- name: secretName,
1555
- idempotency_key: idempotencyKey,
1556
- }),
1557
- annotations: readAnnotations(),
1558
- },
1559
- (input) =>
1560
- toolResult({
1660
+ "Return the stdin-only CLI command for setting a secret; the value never enters MCP. The call runs as the chosen login and first reads the project, so an account that cannot see it answers resource_not_found. The command names that login with --profile-name plus its user and organization (--profile-user, --profile-organization), so a login of the same name that belongs to anyone else is refused, for example on another computer; a server with OHMYHOST_TOKEN instead names its key's user and organization with --token-user and --token-organization, and the CLI then runs only with an OHMYHOST_TOKEN of that account. Either way the later write runs as exactly this account or not at all (acting_as shows which).",
1661
+ z.object({
1662
+ project_id: identifier,
1663
+ environment_id: identifier,
1664
+ name: secretName,
1665
+ idempotency_key: idempotencyKey,
1666
+ }),
1667
+ async (client, input, signal) => {
1668
+ // The project must be visible to the account this call runs as, and the command names it.
1669
+ await client.getProject(input.project_id);
1670
+ const account = await handoverAccount(client, signal);
1671
+ return {
1561
1672
  command: [
1562
1673
  `${commandPrefix} secret set`,
1563
1674
  shellQuote(input.name),
@@ -1567,13 +1678,15 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1567
1678
  shellQuote(input.environment_id),
1568
1679
  "--idempotency-key",
1569
1680
  shellQuote(input.idempotency_key),
1681
+ ...account.flags,
1570
1682
  "--stdin",
1571
1683
  "--wait",
1572
1684
  "--json",
1573
1685
  ].join(" "),
1574
- instruction:
1575
- "Pass the secret value on stdin in a local terminal. Never place it in MCP arguments, prompts, command-line arguments, source, or logs.",
1576
- }),
1686
+ acting_as: client.actingAs?.() ?? null,
1687
+ instruction: `Pass the secret value on stdin in a local terminal; ${account.runs}. Never place the value or a key in MCP arguments, prompts, command-line arguments, source, or logs.`,
1688
+ };
1689
+ },
1577
1690
  );
1578
1691
 
1579
1692
  registerProductTool(
@@ -1647,24 +1760,33 @@ function registerProductTool<Schema extends z.ZodObject<z.ZodRawShape>, Result>(
1647
1760
  dependencies: LocalOhmyhostMcpDependencies,
1648
1761
  name: string,
1649
1762
  description: string,
1650
- inputSchema: Schema,
1763
+ schema: Schema,
1651
1764
  execute: (
1652
1765
  client: LocalMcpProductClient,
1653
1766
  input: z.output<Schema>,
1654
1767
  signal: AbortSignal,
1655
1768
  ) => Promise<Result>,
1656
1769
  annotations = readAnnotations(),
1770
+ selection: (
1771
+ input: z.output<Schema> & { readonly profile_name?: string | undefined },
1772
+ ) => ProfileRequestInput = (input) => {
1773
+ // A call about one organization runs as a login of that organization.
1774
+ const organizationId = (input as Record<string, unknown>)["organization_id"];
1775
+ return {
1776
+ ...(input.profile_name === undefined ? {} : { name: input.profile_name }),
1777
+ ...(typeof organizationId === "string" ? { organizationId } : {}),
1778
+ };
1779
+ },
1657
1780
  ) {
1781
+ const inputSchema = schema.extend({ profile_name: profileName });
1658
1782
  const callback = (async (input: unknown, context: { mcpReq: { signal: AbortSignal } }) => {
1659
- const parsed = inputSchema.parse(input) as z.output<Schema>;
1783
+ const parsed = inputSchema.parse(input) as z.output<Schema> & {
1784
+ readonly profile_name?: string | undefined;
1785
+ };
1786
+ let client: LocalMcpProductClient | undefined;
1660
1787
  try {
1661
- const result = toolResult(
1662
- await execute(
1663
- await dependencies.authenticatedClient(context.mcpReq.signal),
1664
- parsed,
1665
- context.mcpReq.signal,
1666
- ),
1667
- );
1788
+ client = await dependencies.authenticatedClient(context.mcpReq.signal, selection(parsed));
1789
+ const result = toolResult(await execute(client, parsed, context.mcpReq.signal));
1668
1790
  const scope = parsed as Record<string, unknown>;
1669
1791
  if (
1670
1792
  typeof scope["project_id"] === "string" &&
@@ -1680,69 +1802,180 @@ function registerProductTool<Schema extends z.ZodObject<z.ZodRawShape>, Result>(
1680
1802
  });
1681
1803
  return result;
1682
1804
  } catch (error) {
1683
- let safe: {
1684
- code: string;
1685
- retryable: boolean;
1686
- suggestedAction: string;
1687
- retryAfterSeconds?: number;
1688
- requestId?: string;
1689
- };
1690
- try {
1691
- safe =
1692
- error instanceof Error && error.name === "InteractiveSessionRequiredError"
1805
+ return toolError(error, dependencies, client?.actingAs?.());
1806
+ }
1807
+ }) as ToolCallback<typeof inputSchema>;
1808
+ server.registerTool(name, { description, inputSchema, annotations }, callback);
1809
+ }
1810
+
1811
+ /**
1812
+ * The account a command handed to a terminal must run as: a saved login by its name, user and
1813
+ * organization, which the CLI compares with the login of that name wherever it runs, or this
1814
+ * server's OHMYHOST_TOKEN by its key's verified user and organization, which the CLI then demands
1815
+ * of the terminal's own OHMYHOST_TOKEN. No key or token value ever enters the command.
1816
+ */
1817
+ async function handoverAccount(
1818
+ client: LocalMcpProductClient,
1819
+ signal: AbortSignal,
1820
+ ): Promise<{ readonly flags: readonly string[]; readonly runs: string }> {
1821
+ const profile = client.actingAs?.()?.profile ?? null;
1822
+ if (profile !== null) {
1823
+ if (profile.organization_id === null)
1824
+ throw new ProfileSelectionError(
1825
+ "profile_context_mismatch",
1826
+ 2,
1827
+ `The saved login ${profile.name} has no organization, so no command can be tied to its account. Nothing was sent.`,
1828
+ );
1829
+ return {
1830
+ flags: [
1831
+ "--profile-name",
1832
+ shellQuote(profile.name),
1833
+ "--profile-user",
1834
+ shellQuote(profile.user_id),
1835
+ "--profile-organization",
1836
+ shellQuote(profile.organization_id),
1837
+ ],
1838
+ runs: `the command runs only as the saved login it names and only while that login belongs to user ${profile.user_id} in organization ${profile.organization_id}, so a login of the same name that belongs to anyone else, for example on another computer, is refused before anything is read or sent`,
1839
+ };
1840
+ }
1841
+ const report = await client.getCurrentIdentity(signal);
1842
+ const identity = parseCurrentIdentity({
1843
+ actor_id: report.actor_id,
1844
+ organization_ids: report.organization_ids,
1845
+ });
1846
+ const userId = identity.actor_id.startsWith("user:") ? identity.actor_id.slice(5) : "";
1847
+ const [organizationId, ...others] = identity.organization_ids;
1848
+ if (!isProfileUserId(userId) || organizationId === undefined || others.length > 0)
1849
+ throw new ProfileSelectionError(
1850
+ "environment_token_context_mismatch",
1851
+ 2,
1852
+ "This server's OHMYHOST_TOKEN does not belong to one user in one organization, so no command can be tied to its account. Nothing was sent.",
1853
+ );
1854
+ return {
1855
+ flags: ["--token-user", shellQuote(userId), "--token-organization", shellQuote(organizationId)],
1856
+ runs: `run it only where OHMYHOST_TOKEN holds a key of user ${userId} in organization ${organizationId}, loaded from its private env file; it refuses a saved login and any other account's key before anything is sent`,
1857
+ };
1858
+ }
1859
+
1860
+ /** How to add a login for another workspace as the same user; another browser account saves nothing. */
1861
+ function fixedOrganizationAction(
1862
+ error: {
1863
+ readonly profile: { readonly userId: string };
1864
+ readonly organization: { readonly id: string };
1865
+ },
1866
+ commandPrefix: string,
1867
+ ): string {
1868
+ return `This saved login stays in its organization. Add a login for the other workspace with ${commandPrefix} login --organization ${error.organization.id} --user ${error.profile.userId} --json, then pass its profile_name.`;
1869
+ }
1870
+
1871
+ /** The stable, credential-free error of one tool call. */
1872
+ function toolError(
1873
+ error: unknown,
1874
+ dependencies: LocalOhmyhostMcpDependencies,
1875
+ actingAs?: IdentityReport["context"],
1876
+ ): CallToolResult {
1877
+ let safe: {
1878
+ code: string;
1879
+ retryable: boolean;
1880
+ suggestedAction: string;
1881
+ retryAfterSeconds?: number;
1882
+ requestId?: string;
1883
+ };
1884
+ try {
1885
+ safe =
1886
+ error instanceof Error && error.name === "ProfileSelectionError"
1887
+ ? {
1888
+ code: (error as Error & { code: string }).code,
1889
+ retryable: false,
1890
+ suggestedAction: (error as Error & { suggestedAction: string }).suggestedAction,
1891
+ }
1892
+ : error instanceof Error && error.name === "ProfileLimitError"
1893
+ ? {
1894
+ code: "profile_limit_reached",
1895
+ retryable: false,
1896
+ suggestedAction: profileLimitAction(
1897
+ resolveProductCliEnvironment(dependencies.environment).commandPrefix,
1898
+ ),
1899
+ }
1900
+ : error instanceof Error && error.name === "ProfileOrganizationFixedError"
1693
1901
  ? {
1694
- code: "interactive_login_required",
1902
+ code: "profile_organization_fixed",
1695
1903
  retryable: false,
1696
- suggestedAction: `A user API token cannot create, list or select a workspace. Run ${resolveProductCliEnvironment(dependencies.environment).commandPrefix} login --json in a process without OHMYHOST_TOKEN, then retry.`,
1904
+ suggestedAction: fixedOrganizationAction(
1905
+ error as Error & {
1906
+ readonly profile: { readonly userId: string };
1907
+ readonly organization: { readonly id: string };
1908
+ },
1909
+ resolveProductCliEnvironment(dependencies.environment).commandPrefix,
1910
+ ),
1697
1911
  }
1698
- : error instanceof Error && error.message.startsWith("ORGANIZATION_NOT_FOUND:")
1912
+ : error instanceof Error && error.name === "AccountVerificationError"
1699
1913
  ? {
1700
- code: "organization_not_found",
1701
- retryable: false,
1702
- suggestedAction:
1703
- "Call organization_list and choose one of the returned workspace identifiers. Do not create another workspace to recover.",
1914
+ code: "login_verification_failed",
1915
+ retryable: true,
1916
+ suggestedAction: `The signed-in account could not be verified; nothing was saved. Run ${resolveProductCliEnvironment(dependencies.environment).commandPrefix} login --json again.`,
1704
1917
  }
1705
- : error instanceof OperationEventsUnavailableError
1918
+ : error instanceof Error && error.name === "CredentialStoreUnavailableError"
1706
1919
  ? {
1707
- code: "operation_events_unavailable",
1920
+ code: "credential_store_unavailable",
1708
1921
  retryable: true,
1709
1922
  suggestedAction:
1710
- "Read operation_get for current status, then retry operation_logs for this same operation. Do not submit another deployment to recover logs.",
1923
+ "Unlock or repair this computer's credential store and retry. Saved logins were not changed.",
1711
1924
  }
1712
- : error instanceof Error &&
1713
- error.message.startsWith("EXPORT_PASSWORD_FILE_INVALID:")
1925
+ : error instanceof Error && error.name === "InteractiveSessionRequiredError"
1714
1926
  ? {
1715
- code: "export_password_file_invalid",
1927
+ code: "interactive_login_required",
1716
1928
  retryable: false,
1717
- suggestedAction:
1718
- "Choose an existing private UTF-8 password file of 1–1024 bytes. The user retains the file and password. Do not paste the password into MCP arguments.",
1929
+ suggestedAction: `A user API token cannot create, list or select a workspace. Run ${resolveProductCliEnvironment(dependencies.environment).commandPrefix} login --json in a process without OHMYHOST_TOKEN, then retry.`,
1719
1930
  }
1720
- : parseSafeProblem(error);
1721
- } catch {
1722
- safe = {
1723
- code: "client_request_failed",
1724
- retryable: false,
1725
- suggestedAction:
1726
- "Check the connection and run 'ohmyhost login' if authentication expired. Inspect the existing operation before resubmitting any mutation.",
1727
- };
1728
- }
1729
- return {
1730
- ...toolResult({
1731
- error: {
1732
- code: safe.code,
1733
- retryable: safe.retryable,
1734
- suggested_action: safe.suggestedAction,
1735
- ...(safe.requestId === undefined ? {} : { request_id: safe.requestId }),
1736
- ...(safe.retryAfterSeconds === undefined
1737
- ? {}
1738
- : { retry_after_seconds: safe.retryAfterSeconds }),
1739
- },
1740
- }),
1741
- isError: true,
1742
- };
1743
- }
1744
- }) as ToolCallback<Schema>;
1745
- server.registerTool(name, { description, inputSchema, annotations }, callback);
1931
+ : error instanceof Error && error.message.startsWith("ORGANIZATION_NOT_FOUND:")
1932
+ ? {
1933
+ code: "organization_not_found",
1934
+ retryable: false,
1935
+ suggestedAction:
1936
+ "Call organization_list and choose one of the returned workspace identifiers. Do not create another workspace to recover.",
1937
+ }
1938
+ : error instanceof OperationEventsUnavailableError
1939
+ ? {
1940
+ code: "operation_events_unavailable",
1941
+ retryable: true,
1942
+ suggestedAction:
1943
+ "Read operation_get for current status, then retry operation_logs for this same operation. Do not submit another deployment to recover logs.",
1944
+ }
1945
+ : error instanceof Error &&
1946
+ error.message.startsWith("EXPORT_PASSWORD_FILE_INVALID:")
1947
+ ? {
1948
+ code: "export_password_file_invalid",
1949
+ retryable: false,
1950
+ suggestedAction:
1951
+ "Choose an existing private UTF-8 password file of 1–1024 bytes. The user retains the file and password. Do not paste the password into MCP arguments.",
1952
+ }
1953
+ : parseSafeProblem(error);
1954
+ } catch {
1955
+ safe = {
1956
+ code: "client_request_failed",
1957
+ retryable: false,
1958
+ suggestedAction:
1959
+ "Check the connection and run 'ohmyhost login' if authentication expired. Inspect the existing operation before resubmitting any mutation.",
1960
+ };
1961
+ }
1962
+ return {
1963
+ ...toolResult({
1964
+ error: {
1965
+ code: safe.code,
1966
+ retryable: safe.retryable,
1967
+ suggested_action: safe.suggestedAction,
1968
+ ...(safe.requestId === undefined ? {} : { request_id: safe.requestId }),
1969
+ ...(safe.retryAfterSeconds === undefined
1970
+ ? {}
1971
+ : { retry_after_seconds: safe.retryAfterSeconds }),
1972
+ ...(actingAs !== undefined && ACCOUNT_SENSITIVE_PROBLEMS.has(safe.code)
1973
+ ? { acting_as: actingAs }
1974
+ : {}),
1975
+ },
1976
+ }),
1977
+ isError: true,
1978
+ };
1746
1979
  }
1747
1980
 
1748
1981
  function toolResult<Result>(result: Result): CallToolResult {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
- import { resolveProductCliEnvironment } from "@ohmyhost/product-cli";
4
+ import { listSavedProfiles, resolveProductCliEnvironment } from "@ohmyhost/product-cli";
5
5
 
6
6
  import { createAuthenticatedLocalMcpClient } from "./local-client.js";
7
7
  import { createLocalOhmyhostMcpServer } from "./local-server.js";
@@ -13,6 +13,9 @@ const environment = {
13
13
  ...(process.env["OHMYHOST_TOKEN"] === undefined
14
14
  ? {}
15
15
  : { OHMYHOST_TOKEN: process.env["OHMYHOST_TOKEN"] }),
16
+ ...(process.env["OHMYHOST_PROFILE"] === undefined
17
+ ? {}
18
+ : { OHMYHOST_PROFILE: process.env["OHMYHOST_PROFILE"] }),
16
19
  };
17
20
  resolveProductCliEnvironment(environment);
18
21
 
@@ -20,7 +23,13 @@ serveStdio(
20
23
  () =>
21
24
  createLocalOhmyhostMcpServer({
22
25
  environment,
23
- authenticatedClient: (signal) => createAuthenticatedLocalMcpClient(signal, { environment }),
26
+ // Every tool call chooses its own saved login; nothing this process remembers can retarget it.
27
+ authenticatedClient: (signal, request) =>
28
+ createAuthenticatedLocalMcpClient(signal, {
29
+ environment,
30
+ ...(request === undefined ? {} : { request }),
31
+ }),
32
+ listProfiles: (signal) => listSavedProfiles(signal, { environment }),
24
33
  }),
25
34
  { onerror: (error) => process.stderr.write(`${error.message}\n`) },
26
35
  );