@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.
@@ -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
 
@@ -174,7 +181,9 @@ export interface LocalMcpProductClient {
174
181
  organizationId: string,
175
182
  signal: AbortSignal,
176
183
  ): Promise<WorkspaceSelectionReport>;
177
- getCurrentIdentity(signal: AbortSignal): Promise<CurrentIdentity>;
184
+ getCurrentIdentity(signal: AbortSignal): Promise<IdentityReport>;
185
+ /** The account this client acts as, for errors whose meaning depends on it. */
186
+ actingAs?(): IdentityReport["context"];
178
187
  listProjects(input: { readonly cursor?: string; readonly limit?: number }): Promise<ProjectPage>;
179
188
  submitFeedback(input: {
180
189
  report: import("@ohmyhost/sdk-ts").FeedbackSubmission;
@@ -261,21 +270,53 @@ export interface PublicWorkspaceSummary {
261
270
  readonly name: string;
262
271
  }
263
272
 
264
- /** The workspaces of the signed-in user and the one this machine is currently scoped to. */
273
+ /** One saved login as `profile_list` shows it; it never carries a credential. */
274
+ export interface PublicProfileSummary {
275
+ readonly name: string;
276
+ readonly user_id: string;
277
+ readonly organization_id: string | null;
278
+ readonly organization_name: string | null;
279
+ }
280
+
281
+ /** The identity a call acted as, and which saved login or OHMYHOST_TOKEN supplied it. */
282
+ export type IdentityReport = CurrentIdentity & {
283
+ readonly context: {
284
+ readonly credential: "profile" | "environment_token";
285
+ readonly profile: PublicProfileSummary | null;
286
+ };
287
+ };
288
+
289
+ /** The workspaces of the signed-in user and the one the chosen login is scoped to. */
265
290
  export interface WorkspaceReport {
266
291
  readonly organizations: readonly PublicWorkspaceSummary[];
267
292
  readonly selected: string | null;
293
+ readonly profile: PublicProfileSummary;
268
294
  }
269
295
 
270
296
  /** One workspace that was just created or selected, with the resulting scope. */
271
297
  export interface WorkspaceSelectionReport {
272
298
  readonly organization: PublicWorkspaceSummary;
273
299
  readonly selected: string;
300
+ readonly profile: PublicProfileSummary;
301
+ readonly next_action?: string;
302
+ }
303
+
304
+ /** The saved logins of this computer and how this MCP process chooses among them. */
305
+ export interface ProfileListReport {
306
+ readonly profiles: readonly PublicProfileSummary[];
307
+ readonly bound: string | null;
308
+ readonly environment_token: boolean;
274
309
  }
275
310
 
276
311
  export interface LocalOhmyhostMcpDependencies {
277
312
  readonly environment?: ProductCliEnvironment;
278
- authenticatedClient(signal: AbortSignal): Promise<LocalMcpProductClient>;
313
+ /** One client for one call; the request names the login and organization that call is about. */
314
+ authenticatedClient(
315
+ signal: AbortSignal,
316
+ request?: ProfileRequestInput,
317
+ ): Promise<LocalMcpProductClient>;
318
+ /** Saved logins, read without choosing one; absent where no local store exists. */
319
+ listProfiles?(signal: AbortSignal): Promise<ProfileListReport>;
279
320
  }
280
321
 
281
322
  interface ProjectCreateInput {
@@ -351,6 +392,14 @@ interface ProjectDeleteInput {
351
392
  }
352
393
 
353
394
  const identifier = z.string().min(1).max(256);
395
+ /** Every product tool accepts the saved login to act as; it is an alias, never a credential. */
396
+ const profileName = z
397
+ .string()
398
+ .regex(/^[a-z0-9][a-z0-9_-]{0,62}$/u)
399
+ .optional()
400
+ .describe(
401
+ "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.",
402
+ );
354
403
  const idempotencyKey = z.string().min(1).max(256);
355
404
  const commitSha = z.string().regex(/^[a-f0-9]{40}$/u);
356
405
  const domainName = z
@@ -700,7 +749,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
700
749
  server,
701
750
  dependencies,
702
751
  "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.`,
752
+ `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
753
  z.object({
705
754
  name: z.string().min(1).max(128),
706
755
  signup_source: z
@@ -724,7 +773,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
724
773
  server,
725
774
  dependencies,
726
775
  "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.",
776
+ "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
777
  z.object({}),
729
778
  (client) => client.listOrganizations(),
730
779
  );
@@ -732,10 +781,32 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
732
781
  server,
733
782
  dependencies,
734
783
  "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.`,
784
+ `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
785
  z.object({ organization_id: identifier }),
737
786
  (client, input, signal) => client.selectOrganization(input.organization_id, signal),
738
787
  mutationAnnotations(),
788
+ (input) => ({
789
+ ...(input.profile_name === undefined ? {} : { name: input.profile_name }),
790
+ organizationId: input.organization_id,
791
+ bindOrganization: true,
792
+ }),
793
+ );
794
+
795
+ server.registerTool(
796
+ "profile_list",
797
+ {
798
+ 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.`,
799
+ inputSchema: z.object({}),
800
+ annotations: readAnnotations(),
801
+ },
802
+ (async (_input: unknown, context: { mcpReq: { signal: AbortSignal } }) => {
803
+ try {
804
+ if (dependencies.listProfiles === undefined) throw new CredentialStoreUnavailableError();
805
+ return toolResult(await dependencies.listProfiles(context.mcpReq.signal));
806
+ } catch (error) {
807
+ return toolError(error, dependencies);
808
+ }
809
+ }) as ToolCallback<z.ZodObject<Record<string, never>>>,
739
810
  );
740
811
 
741
812
  registerProductTool(
@@ -940,7 +1011,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
940
1011
  server,
941
1012
  dependencies,
942
1013
  "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.",
1014
+ "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
1015
  z.object({}),
945
1016
  (client, _input, signal) => client.getCurrentIdentity(signal),
946
1017
  );
@@ -1144,7 +1215,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1144
1215
  server,
1145
1216
  dependencies,
1146
1217
  "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.",
1218
+ "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
1219
  z.object({ organization_id: identifier }),
1149
1220
  (client, input) => client.getGithubConnection(input.organization_id),
1150
1221
  );
@@ -1152,7 +1223,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1152
1223
  server,
1153
1224
  dependencies,
1154
1225
  "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.",
1226
+ "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
1227
  z.object({
1157
1228
  project_id: identifier,
1158
1229
  repository_owner: identifier,
@@ -1543,21 +1614,22 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1543
1614
  destructiveAnnotations(),
1544
1615
  );
1545
1616
 
1546
- server.registerTool(
1617
+ registerProductTool(
1618
+ server,
1619
+ dependencies,
1547
1620
  "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({
1621
+ "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).",
1622
+ z.object({
1623
+ project_id: identifier,
1624
+ environment_id: identifier,
1625
+ name: secretName,
1626
+ idempotency_key: idempotencyKey,
1627
+ }),
1628
+ async (client, input, signal) => {
1629
+ // The project must be visible to the account this call runs as, and the command names it.
1630
+ await client.getProject(input.project_id);
1631
+ const account = await handoverAccount(client, signal);
1632
+ return {
1561
1633
  command: [
1562
1634
  `${commandPrefix} secret set`,
1563
1635
  shellQuote(input.name),
@@ -1567,13 +1639,15 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1567
1639
  shellQuote(input.environment_id),
1568
1640
  "--idempotency-key",
1569
1641
  shellQuote(input.idempotency_key),
1642
+ ...account.flags,
1570
1643
  "--stdin",
1571
1644
  "--wait",
1572
1645
  "--json",
1573
1646
  ].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
- }),
1647
+ acting_as: client.actingAs?.() ?? null,
1648
+ 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.`,
1649
+ };
1650
+ },
1577
1651
  );
1578
1652
 
1579
1653
  registerProductTool(
@@ -1647,24 +1721,33 @@ function registerProductTool<Schema extends z.ZodObject<z.ZodRawShape>, Result>(
1647
1721
  dependencies: LocalOhmyhostMcpDependencies,
1648
1722
  name: string,
1649
1723
  description: string,
1650
- inputSchema: Schema,
1724
+ schema: Schema,
1651
1725
  execute: (
1652
1726
  client: LocalMcpProductClient,
1653
1727
  input: z.output<Schema>,
1654
1728
  signal: AbortSignal,
1655
1729
  ) => Promise<Result>,
1656
1730
  annotations = readAnnotations(),
1731
+ selection: (
1732
+ input: z.output<Schema> & { readonly profile_name?: string | undefined },
1733
+ ) => ProfileRequestInput = (input) => {
1734
+ // A call about one organization runs as a login of that organization.
1735
+ const organizationId = (input as Record<string, unknown>)["organization_id"];
1736
+ return {
1737
+ ...(input.profile_name === undefined ? {} : { name: input.profile_name }),
1738
+ ...(typeof organizationId === "string" ? { organizationId } : {}),
1739
+ };
1740
+ },
1657
1741
  ) {
1742
+ const inputSchema = schema.extend({ profile_name: profileName });
1658
1743
  const callback = (async (input: unknown, context: { mcpReq: { signal: AbortSignal } }) => {
1659
- const parsed = inputSchema.parse(input) as z.output<Schema>;
1744
+ const parsed = inputSchema.parse(input) as z.output<Schema> & {
1745
+ readonly profile_name?: string | undefined;
1746
+ };
1747
+ let client: LocalMcpProductClient | undefined;
1660
1748
  try {
1661
- const result = toolResult(
1662
- await execute(
1663
- await dependencies.authenticatedClient(context.mcpReq.signal),
1664
- parsed,
1665
- context.mcpReq.signal,
1666
- ),
1667
- );
1749
+ client = await dependencies.authenticatedClient(context.mcpReq.signal, selection(parsed));
1750
+ const result = toolResult(await execute(client, parsed, context.mcpReq.signal));
1668
1751
  const scope = parsed as Record<string, unknown>;
1669
1752
  if (
1670
1753
  typeof scope["project_id"] === "string" &&
@@ -1680,69 +1763,180 @@ function registerProductTool<Schema extends z.ZodObject<z.ZodRawShape>, Result>(
1680
1763
  });
1681
1764
  return result;
1682
1765
  } 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"
1766
+ return toolError(error, dependencies, client?.actingAs?.());
1767
+ }
1768
+ }) as ToolCallback<typeof inputSchema>;
1769
+ server.registerTool(name, { description, inputSchema, annotations }, callback);
1770
+ }
1771
+
1772
+ /**
1773
+ * The account a command handed to a terminal must run as: a saved login by its name, user and
1774
+ * organization, which the CLI compares with the login of that name wherever it runs, or this
1775
+ * server's OHMYHOST_TOKEN by its key's verified user and organization, which the CLI then demands
1776
+ * of the terminal's own OHMYHOST_TOKEN. No key or token value ever enters the command.
1777
+ */
1778
+ async function handoverAccount(
1779
+ client: LocalMcpProductClient,
1780
+ signal: AbortSignal,
1781
+ ): Promise<{ readonly flags: readonly string[]; readonly runs: string }> {
1782
+ const profile = client.actingAs?.()?.profile ?? null;
1783
+ if (profile !== null) {
1784
+ if (profile.organization_id === null)
1785
+ throw new ProfileSelectionError(
1786
+ "profile_context_mismatch",
1787
+ 2,
1788
+ `The saved login ${profile.name} has no organization, so no command can be tied to its account. Nothing was sent.`,
1789
+ );
1790
+ return {
1791
+ flags: [
1792
+ "--profile-name",
1793
+ shellQuote(profile.name),
1794
+ "--profile-user",
1795
+ shellQuote(profile.user_id),
1796
+ "--profile-organization",
1797
+ shellQuote(profile.organization_id),
1798
+ ],
1799
+ 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`,
1800
+ };
1801
+ }
1802
+ const report = await client.getCurrentIdentity(signal);
1803
+ const identity = parseCurrentIdentity({
1804
+ actor_id: report.actor_id,
1805
+ organization_ids: report.organization_ids,
1806
+ });
1807
+ const userId = identity.actor_id.startsWith("user:") ? identity.actor_id.slice(5) : "";
1808
+ const [organizationId, ...others] = identity.organization_ids;
1809
+ if (!isProfileUserId(userId) || organizationId === undefined || others.length > 0)
1810
+ throw new ProfileSelectionError(
1811
+ "environment_token_context_mismatch",
1812
+ 2,
1813
+ "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.",
1814
+ );
1815
+ return {
1816
+ flags: ["--token-user", shellQuote(userId), "--token-organization", shellQuote(organizationId)],
1817
+ 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`,
1818
+ };
1819
+ }
1820
+
1821
+ /** How to add a login for another workspace as the same user; another browser account saves nothing. */
1822
+ function fixedOrganizationAction(
1823
+ error: {
1824
+ readonly profile: { readonly userId: string };
1825
+ readonly organization: { readonly id: string };
1826
+ },
1827
+ commandPrefix: string,
1828
+ ): string {
1829
+ 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.`;
1830
+ }
1831
+
1832
+ /** The stable, credential-free error of one tool call. */
1833
+ function toolError(
1834
+ error: unknown,
1835
+ dependencies: LocalOhmyhostMcpDependencies,
1836
+ actingAs?: IdentityReport["context"],
1837
+ ): CallToolResult {
1838
+ let safe: {
1839
+ code: string;
1840
+ retryable: boolean;
1841
+ suggestedAction: string;
1842
+ retryAfterSeconds?: number;
1843
+ requestId?: string;
1844
+ };
1845
+ try {
1846
+ safe =
1847
+ error instanceof Error && error.name === "ProfileSelectionError"
1848
+ ? {
1849
+ code: (error as Error & { code: string }).code,
1850
+ retryable: false,
1851
+ suggestedAction: (error as Error & { suggestedAction: string }).suggestedAction,
1852
+ }
1853
+ : error instanceof Error && error.name === "ProfileLimitError"
1854
+ ? {
1855
+ code: "profile_limit_reached",
1856
+ retryable: false,
1857
+ suggestedAction: profileLimitAction(
1858
+ resolveProductCliEnvironment(dependencies.environment).commandPrefix,
1859
+ ),
1860
+ }
1861
+ : error instanceof Error && error.name === "ProfileOrganizationFixedError"
1693
1862
  ? {
1694
- code: "interactive_login_required",
1863
+ code: "profile_organization_fixed",
1695
1864
  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.`,
1865
+ suggestedAction: fixedOrganizationAction(
1866
+ error as Error & {
1867
+ readonly profile: { readonly userId: string };
1868
+ readonly organization: { readonly id: string };
1869
+ },
1870
+ resolveProductCliEnvironment(dependencies.environment).commandPrefix,
1871
+ ),
1697
1872
  }
1698
- : error instanceof Error && error.message.startsWith("ORGANIZATION_NOT_FOUND:")
1873
+ : error instanceof Error && error.name === "AccountVerificationError"
1699
1874
  ? {
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.",
1875
+ code: "login_verification_failed",
1876
+ retryable: true,
1877
+ suggestedAction: `The signed-in account could not be verified; nothing was saved. Run ${resolveProductCliEnvironment(dependencies.environment).commandPrefix} login --json again.`,
1704
1878
  }
1705
- : error instanceof OperationEventsUnavailableError
1879
+ : error instanceof Error && error.name === "CredentialStoreUnavailableError"
1706
1880
  ? {
1707
- code: "operation_events_unavailable",
1881
+ code: "credential_store_unavailable",
1708
1882
  retryable: true,
1709
1883
  suggestedAction:
1710
- "Read operation_get for current status, then retry operation_logs for this same operation. Do not submit another deployment to recover logs.",
1884
+ "Unlock or repair this computer's credential store and retry. Saved logins were not changed.",
1711
1885
  }
1712
- : error instanceof Error &&
1713
- error.message.startsWith("EXPORT_PASSWORD_FILE_INVALID:")
1886
+ : error instanceof Error && error.name === "InteractiveSessionRequiredError"
1714
1887
  ? {
1715
- code: "export_password_file_invalid",
1888
+ code: "interactive_login_required",
1716
1889
  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.",
1890
+ 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
1891
  }
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);
1892
+ : error instanceof Error && error.message.startsWith("ORGANIZATION_NOT_FOUND:")
1893
+ ? {
1894
+ code: "organization_not_found",
1895
+ retryable: false,
1896
+ suggestedAction:
1897
+ "Call organization_list and choose one of the returned workspace identifiers. Do not create another workspace to recover.",
1898
+ }
1899
+ : error instanceof OperationEventsUnavailableError
1900
+ ? {
1901
+ code: "operation_events_unavailable",
1902
+ retryable: true,
1903
+ suggestedAction:
1904
+ "Read operation_get for current status, then retry operation_logs for this same operation. Do not submit another deployment to recover logs.",
1905
+ }
1906
+ : error instanceof Error &&
1907
+ error.message.startsWith("EXPORT_PASSWORD_FILE_INVALID:")
1908
+ ? {
1909
+ code: "export_password_file_invalid",
1910
+ retryable: false,
1911
+ suggestedAction:
1912
+ "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.",
1913
+ }
1914
+ : parseSafeProblem(error);
1915
+ } catch {
1916
+ safe = {
1917
+ code: "client_request_failed",
1918
+ retryable: false,
1919
+ suggestedAction:
1920
+ "Check the connection and run 'ohmyhost login' if authentication expired. Inspect the existing operation before resubmitting any mutation.",
1921
+ };
1922
+ }
1923
+ return {
1924
+ ...toolResult({
1925
+ error: {
1926
+ code: safe.code,
1927
+ retryable: safe.retryable,
1928
+ suggested_action: safe.suggestedAction,
1929
+ ...(safe.requestId === undefined ? {} : { request_id: safe.requestId }),
1930
+ ...(safe.retryAfterSeconds === undefined
1931
+ ? {}
1932
+ : { retry_after_seconds: safe.retryAfterSeconds }),
1933
+ ...(actingAs !== undefined && ACCOUNT_SENSITIVE_PROBLEMS.has(safe.code)
1934
+ ? { acting_as: actingAs }
1935
+ : {}),
1936
+ },
1937
+ }),
1938
+ isError: true,
1939
+ };
1746
1940
  }
1747
1941
 
1748
1942
  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
  );