@cloudgrid-io/mcp 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +4 -2
  2. package/src/tools.js +104 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "MCP server for CloudGrid. Two editions: a local stdio server (full toolset) and a hosted web server (light, CLI-free toolset) over MCP Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,9 +22,11 @@
22
22
  "smoke:web": "node test/smoke-web.mjs",
23
23
  "smoke:redrop": "node test/smoke-redrop.mjs",
24
24
  "smoke:oauth": "node test/smoke-oauth.mjs",
25
- "test:auth": "node test/auth.test.mjs"
25
+ "test:auth": "node test/auth.test.mjs",
26
+ "build:mcpb": "node scripts/build-mcpb.mjs"
26
27
  },
27
28
  "dependencies": {
29
+ "@cloudgrid-io/cli": "^0.9.20",
28
30
  "@modelcontextprotocol/sdk": "^1.0.0",
29
31
  "express": "^4.22.2",
30
32
  "zod": "^3.23.0"
package/src/tools.js CHANGED
@@ -9,10 +9,11 @@
9
9
  // The difference is injected as a `ctx` object, so the tool logic is written once.
10
10
 
11
11
  import { execFile } from "node:child_process";
12
+ import { createRequire } from "node:module";
12
13
  import { promisify } from "node:util";
13
14
  import { readFile } from "node:fs/promises";
14
15
  import { readFileSync, existsSync, statSync } from "node:fs";
15
- import { basename, resolve } from "node:path";
16
+ import { basename, dirname, resolve, join } from "node:path";
16
17
  import { z } from "zod";
17
18
  import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
18
19
 
@@ -74,15 +75,52 @@ function resolveCwd(cwd) {
74
75
  return abs;
75
76
  }
76
77
 
78
+ // Resolve the bundled CLI entry point from this package's own node_modules.
79
+ // Returns the absolute path to the JS entry, or null if unresolvable.
80
+ function resolveBundledCli() {
81
+ try {
82
+ const require = createRequire(import.meta.url);
83
+ const pkgPath = require.resolve("@cloudgrid-io/cli/package.json");
84
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
85
+ const bin = pkg.bin && pkg.bin.cloudgrid;
86
+ if (!bin) return null;
87
+ return join(dirname(pkgPath), bin);
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
77
93
  async function runCloudgrid(args, opts = {}) {
78
94
  const cwd = resolveCwd(opts.cwd);
95
+ const execOpts = {
96
+ maxBuffer: 16 * 1024 * 1024,
97
+ timeout: 10 * 60 * 1000,
98
+ stdio: ["ignore", "pipe", "pipe"],
99
+ ...(cwd ? { cwd } : {}),
100
+ };
101
+
102
+ // 1. Try the bundled CLI (node <cliEntry> ...args)
103
+ const bundled = resolveBundledCli();
104
+ if (bundled) {
105
+ try {
106
+ const { stdout, stderr } = await execFileAsync(
107
+ process.execPath,
108
+ [bundled, ...args],
109
+ execOpts,
110
+ );
111
+ return (stdout || stderr || "").trim() || "Done.";
112
+ } catch (err) {
113
+ const detail = [err && err.stdout, err && err.stderr, err && err.message]
114
+ .filter(Boolean)
115
+ .join("\n")
116
+ .trim();
117
+ throw new Error(detail || "cloudgrid command failed");
118
+ }
119
+ }
120
+
121
+ // 2. Fallback: global `cloudgrid` on PATH
79
122
  try {
80
- const { stdout, stderr } = await execFileAsync("cloudgrid", args, {
81
- maxBuffer: 16 * 1024 * 1024,
82
- timeout: 10 * 60 * 1000,
83
- stdio: ["ignore", "pipe", "pipe"],
84
- ...(cwd ? { cwd } : {}),
85
- });
123
+ const { stdout, stderr } = await execFileAsync("cloudgrid", args, execOpts);
86
124
  return (stdout || stderr || "").trim() || "Done.";
87
125
  } catch (err) {
88
126
  if (err && err.code === "ENOENT") {
@@ -563,6 +601,7 @@ export function registerTools(server, ctx) {
563
601
  slug: z.string().describe("Org slug to pass as the org parameter."),
564
602
  name: z.string().describe("Human-readable org name."),
565
603
  role: z.string().describe("User's role in the org."),
604
+ is_active: z.boolean().optional().describe("True if this is the user's currently active org."),
566
605
  })).optional().describe("The user's orgs, when org choice is needed."),
567
606
  needs_sign_in: z.boolean().optional().describe("True when sign-in is needed before dropping."),
568
607
  login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
@@ -596,11 +635,16 @@ export function registerTools(server, ctx) {
596
635
  structured: { needs_sign_in: true, login_url: url },
597
636
  });
598
637
  }
599
- // Stateless org disambiguation — no dependency on prior-call state
600
- // so it works even when the client reconnects on every tool call
601
- // (ChatGPT Apps SDK behaviour).
602
- {
638
+ }
639
+
640
+ // Stateless org disambiguation — both editions, when authenticated.
641
+ // No dependency on prior-call state so it works even when the client
642
+ // reconnects on every tool call (ChatGPT Apps SDK behaviour).
643
+ if (input?.anonymous !== true) {
644
+ const token = await ctx.getToken();
645
+ if (token) {
603
646
  const orgs = await fetchUserOrgs(token);
647
+ const activeOrg = await ctx.getActiveOrg();
604
648
  const suppliedOrg = input?.org;
605
649
  const validOrg = suppliedOrg && orgs.some((o) => o.slug === suppliedOrg);
606
650
  if (validOrg) {
@@ -608,13 +652,22 @@ export function registerTools(server, ctx) {
608
652
  input = { ...(input || {}), org: suppliedOrg };
609
653
  } else if (orgs.length > 1) {
610
654
  // No valid org supplied and multiple orgs — ask once.
655
+ // Mark the active org so the agent can offer it as the default.
656
+ const annotated = orgs.map((o) => ({
657
+ ...o,
658
+ is_active: o.slug === activeOrg,
659
+ }));
660
+ annotated.sort((a, b) => (b.is_active ? 1 : 0) - (a.is_active ? 1 : 0));
611
661
  const lines = ["Which org should this be published to?"];
612
- for (const o of orgs) lines.push(` ${o.slug} — ${o.name} (${o.role})`);
662
+ for (const o of annotated) {
663
+ const tag = o.is_active ? " (your active org)" : "";
664
+ lines.push(` ${o.slug} — ${o.name} (${o.role})${tag}`);
665
+ }
613
666
  lines.push("Pass the org slug in the org parameter to publish.");
614
667
  return okResult({
615
668
  text: lines.join("\n"),
616
- structured: { needs_org: true, orgs },
617
- meta: { "openai/outputTemplate": ORG_PICKER_URI },
669
+ structured: { needs_org: true, orgs: annotated },
670
+ ...(ctx.edition === "web" ? { meta: { "openai/outputTemplate": ORG_PICKER_URI } } : {}),
618
671
  });
619
672
  } else if (orgs.length === 1) {
620
673
  // Single org — publish to it silently.
@@ -753,36 +806,44 @@ export function registerTools(server, ctx) {
753
806
  },
754
807
  );
755
808
 
756
- // Org listing — web edition only (local edition uses cloudgrid_whoami).
757
- if (ctx.edition === "web") {
758
- server.registerTool(
759
- "cloudgrid_orgs",
760
- {
761
- description: "List the signed-in user's organizations. Returns each org's slug, name, and the user's role. Use to discover which org to publish to. Requires sign-in.",
762
- inputSchema: {},
763
- outputSchema: {
764
- orgs: z.array(z.object({
765
- slug: z.string().describe("Org slug."),
766
- name: z.string().describe("Human-readable org name."),
767
- role: z.string().describe("User's role in the org."),
768
- })).describe("The user's org memberships."),
769
- },
770
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
771
- },
772
- async () => {
773
- const token = await ctx.getToken();
774
- if (!token) {
775
- return fail("You are not signed in. Run cloudgrid_login first.");
776
- }
777
- const orgs = await fetchUserOrgs(token);
778
- if (orgs.length === 0) {
779
- return okResult({ text: "No organizations found.", structured: { orgs: [] } });
780
- }
781
- const lines = orgs.map((o) => `${o.slug} — ${o.name} (${o.role})`);
782
- return okResult({ text: lines.join("\n"), structured: { orgs } });
809
+ // Org listing — both editions.
810
+ server.registerTool(
811
+ "cloudgrid_orgs",
812
+ {
813
+ description: "List the signed-in user's organizations. Returns each org's slug, name, and the user's role. Use to discover which org to publish to. Requires sign-in.",
814
+ inputSchema: {},
815
+ outputSchema: {
816
+ orgs: z.array(z.object({
817
+ slug: z.string().describe("Org slug."),
818
+ name: z.string().describe("Human-readable org name."),
819
+ role: z.string().describe("User's role in the org."),
820
+ is_active: z.boolean().optional().describe("True if this is the user's currently active org."),
821
+ })).describe("The user's org memberships."),
783
822
  },
784
- );
785
- }
823
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
824
+ },
825
+ async () => {
826
+ const token = await ctx.getToken();
827
+ if (!token) {
828
+ return fail("You are not signed in. Run cloudgrid_login first.");
829
+ }
830
+ const orgs = await fetchUserOrgs(token);
831
+ if (orgs.length === 0) {
832
+ return okResult({ text: "No organizations found.", structured: { orgs: [] } });
833
+ }
834
+ const activeOrg = await ctx.getActiveOrg();
835
+ const annotated = orgs.map((o) => ({
836
+ ...o,
837
+ is_active: o.slug === activeOrg,
838
+ }));
839
+ annotated.sort((a, b) => (b.is_active ? 1 : 0) - (a.is_active ? 1 : 0));
840
+ const lines = annotated.map((o) => {
841
+ const tag = o.is_active ? " (your active org)" : "";
842
+ return `${o.slug} — ${o.name} (${o.role})${tag}`;
843
+ });
844
+ return okResult({ text: lines.join("\n"), structured: { orgs: annotated } });
845
+ },
846
+ );
786
847
 
787
848
  if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
788
849