@greenflags/mcp 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GreenFlags
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # @greenflags/mcp
2
+
3
+ Connect Claude Desktop (or any MCP client) to GreenFlags to manage feature flags straight from the AI.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js ≥ 20
8
+ - Access to a GreenFlags workspace (admin account for step 1)
9
+
10
+ ---
11
+
12
+ ## Setup
13
+
14
+ ### Step 1 — Register the app (workspace admin, one time only)
15
+
16
+ In GreenFlags, go to **Settings → Developer Apps** and create a new app:
17
+
18
+ | Field | Value |
19
+ |-------|-------|
20
+ | App name | `Claude Desktop MCP` |
21
+ | Redirect URI | `http://localhost:9876/callback` |
22
+ | Scopes | whichever you want to grant access to |
23
+
24
+ Save the **Client ID** shown after creation. You'll need it in step 2.
25
+
26
+ ---
27
+
28
+ ### Step 2 — Configure Claude Desktop
29
+
30
+ Edit your Claude Desktop config file:
31
+
32
+ - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
33
+ - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "greenflags": {
39
+ "command": "npx",
40
+ "args": ["-y", "@greenflags/mcp"],
41
+ "env": {
42
+ "GREENFLAGS_URL": "https://your-greenflags-instance.com",
43
+ "GREENFLAGS_CLIENT_ID": "the-client-id-from-step-1",
44
+ "GREENFLAGS_PROJECT_ID": "optional-default-project-id"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ `npx -y` downloads and runs the published package on demand — no local build required.
52
+
53
+ `GREENFLAGS_PROJECT_ID` is optional (see **Default project**).
54
+
55
+ Restart Claude Desktop.
56
+
57
+ > If you don't set `GREENFLAGS_CLIENT_ID`, it defaults to `greenflags-mcp-client` (only valid if you seeded that row manually).
58
+
59
+ ---
60
+
61
+ ### Local development setup (alternative to npx)
62
+
63
+ If you're working on this repo and want to run the server from source instead of the published package:
64
+
65
+ ```bash
66
+ make mcp-install
67
+ make mcp-build
68
+ ```
69
+
70
+ Then point your config at the built file directly:
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "greenflags": {
75
+ "command": "node",
76
+ "args": ["/absolute/path/to/repo/mcp/dist/index.js"],
77
+ "env": { "GREENFLAGS_URL": "...", "GREENFLAGS_CLIENT_ID": "..." }
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Default project
86
+
87
+ If you set `GREENFLAGS_PROJECT_ID` in the `env` block, the `project` parameter becomes **optional** on every tool — the AI doesn't need to pass it on each call. If the AI passes an explicit `project`, that one wins.
88
+
89
+ Without the env var, use `list_projects` first to discover available projects and pass `project` (id or slug) on every tool call.
90
+
91
+ ---
92
+
93
+ ## Identifiers
94
+
95
+ Every tool accepts **key/slug or id** interchangeably:
96
+
97
+ - `project` → project id or slug
98
+ - `flag` → flag key (preferred) or id
99
+ - `environment` → environment slug (preferred) or id
100
+
101
+ The MCP resolves these to ids internally — you don't need to know the ids.
102
+
103
+ ---
104
+
105
+ ## Sensitive environments
106
+
107
+ Environments flagged `is_sensitive` (typically prod) are protected. A `set_flag_value` write against a sensitive environment **is refused** unless you include `confirm: true`. Use `list_environments` to see which ones are sensitive.
108
+
109
+ ---
110
+
111
+ ## First use
112
+
113
+ The first time you use a GreenFlags tool in Claude Desktop, it opens the browser to log in and approve access. Once approved, access is saved and won't be requested again (until the token expires in 1 hour).
114
+
115
+ ---
116
+
117
+ ## Re-authenticating
118
+
119
+ If access expires or you want to switch accounts:
120
+
121
+ ```bash
122
+ rm ~/.greenflags/tokens.json
123
+ ```
124
+
125
+ The next tool call will open the browser again.
126
+
127
+ ---
128
+
129
+ ## Available tools
130
+
131
+ | Tool | What it does |
132
+ |------|----------|
133
+ | `list_projects` | List the workspace's projects (entry point) |
134
+ | `list_flags` | List flags with `status`/`search` filters and per-environment value |
135
+ | `get_flag` | Get a flag by key or id |
136
+ | `get_flag_values` | A flag's value across all environments (or one) |
137
+ | `list_environments` | List environments, flags which ones are sensitive |
138
+ | `create_flag` | Create a flag (typed: boolean/string/number/json) |
139
+ | `update_flag` | Rename / change a flag's description |
140
+ | `archive_flag` | Archive a flag (soft-delete) |
141
+ | `set_flag_value` | Set a flag's typed value in an environment |
142
+
143
+ All read tools return `structuredContent` (canonical JSON) in addition to a text summary.
144
+
145
+ ---
146
+
147
+ ## Breaking changes (0.2.0)
148
+
149
+ - **`toggle_flag_value` removed.** Use **`set_flag_value`** instead. It's typed — pass exactly one of:
150
+ - `value_boolean: true|false`
151
+ - `value_string: "..."`
152
+ - `value_number: 123`
153
+ - `value_json: { ... }`
154
+
155
+ Defaults to the flag's declared `type`. For sensitive environments, add `confirm: true`.
156
+ - New tools: `list_projects`, `get_flag_values`, `update_flag`.
157
+ - Every tool accepts key/slug or id (see **Identifiers**) and supports `GREENFLAGS_PROJECT_ID` (see **Default project**).
158
+
159
+ ---
160
+
161
+ ## Security
162
+
163
+ - Tokens are stored in plaintext at `~/.greenflags/tokens.json`. Don't use this MCP server on shared computers.
164
+ - The token expires after 1 hour. You can delete it manually at any time.
package/dist/api.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function callApi<T>(path: string, method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE", data?: unknown, baseUrl?: string): Promise<T>;
package/dist/api.js ADDED
@@ -0,0 +1,46 @@
1
+ import axios from "axios";
2
+ import { GREENFLAGS_BASE_URL } from "./config.js";
3
+ import { getValidToken } from "./auth.js";
4
+ import { ApiError } from "./errors.js";
5
+ export async function callApi(path, method = "GET", data, baseUrl = GREENFLAGS_BASE_URL) {
6
+ const token = await getValidToken(baseUrl);
7
+ try {
8
+ const res = await axios.request({
9
+ method,
10
+ url: `${baseUrl}${path}`,
11
+ headers: {
12
+ Authorization: `Bearer ${token}`,
13
+ "Content-Type": "application/json",
14
+ },
15
+ data,
16
+ });
17
+ return res.data.data;
18
+ }
19
+ catch (e) {
20
+ const err = e;
21
+ const status = err.response?.status ?? 0;
22
+ const body = err.response?.data;
23
+ const backendCode = body?.error;
24
+ const backendMsg = body?.message ?? err.message;
25
+ const details = body?.details;
26
+ if (status === 401) {
27
+ throw new ApiError("AUTH_EXPIRED", "Token expired or invalid. Re-authenticate (delete ~/.greenflags/tokens.json and call any tool to re-trigger OAuth).", 401);
28
+ }
29
+ if (status === 403) {
30
+ throw new ApiError(backendCode ?? "FORBIDDEN", backendMsg, 403, details);
31
+ }
32
+ if (status === 404) {
33
+ throw new ApiError(backendCode ?? "NOT_FOUND", `${backendMsg} (path: ${path})`, 404);
34
+ }
35
+ if (status === 409) {
36
+ throw new ApiError(backendCode ?? "CONFLICT", backendMsg, 409, details);
37
+ }
38
+ if (status === 422) {
39
+ throw new ApiError(backendCode ?? "VALIDATION_FAILED", backendMsg, 422, details);
40
+ }
41
+ if (status >= 500) {
42
+ throw new ApiError("API_ERROR", `Backend ${status}: ${backendMsg}`, status, details);
43
+ }
44
+ throw new ApiError(backendCode ?? "UNKNOWN", backendMsg, status, details);
45
+ }
46
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function getValidToken(baseUrl?: string): Promise<string>;
package/dist/auth.js ADDED
@@ -0,0 +1,100 @@
1
+ import { createHash, randomBytes } from "crypto";
2
+ import { createServer } from "http";
3
+ import { URL } from "url";
4
+ import open from "open";
5
+ import axios from "axios";
6
+ import { GREENFLAGS_BASE_URL, MCP_CALLBACK_PORT, MCP_CLIENT_ID, MCP_REDIRECT_URI, loadTokens, saveTokens, } from "./config.js";
7
+ function generatePkce() {
8
+ const verifier = randomBytes(64)
9
+ .toString("base64url")
10
+ .slice(0, 96);
11
+ const challenge = createHash("sha256")
12
+ .update(verifier)
13
+ .digest("base64url");
14
+ return { verifier, challenge };
15
+ }
16
+ function generateState() {
17
+ return randomBytes(16).toString("hex");
18
+ }
19
+ async function authenticate(baseUrl) {
20
+ const { verifier, challenge } = generatePkce();
21
+ const state = generateState();
22
+ const port = MCP_CALLBACK_PORT;
23
+ const redirectUri = MCP_REDIRECT_URI;
24
+ // Open the browser-facing SPA consent page (not the /oauth/authorize JSON API,
25
+ // which requires a Bearer header a top-level navigation can't send).
26
+ const authorizeUrl = new URL(`${baseUrl}/authorize`);
27
+ authorizeUrl.searchParams.set("client_id", MCP_CLIENT_ID);
28
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
29
+ authorizeUrl.searchParams.set("response_type", "code");
30
+ authorizeUrl.searchParams.set("scope", "flags:read flags:write environments:read audit:read");
31
+ authorizeUrl.searchParams.set("code_challenge", challenge);
32
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
33
+ authorizeUrl.searchParams.set("state", state);
34
+ const code = await new Promise((resolve, reject) => {
35
+ const timeout = setTimeout(() => {
36
+ server.close();
37
+ reject(new Error("OAuth timeout: no callback received within 5 minutes"));
38
+ }, 5 * 60 * 1000);
39
+ const server = createServer((req, res) => {
40
+ const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
41
+ const receivedCode = reqUrl.searchParams.get("code");
42
+ const receivedState = reqUrl.searchParams.get("state");
43
+ const error = reqUrl.searchParams.get("error");
44
+ res.writeHead(200, { "Content-Type": "text/html" });
45
+ res.end("<html><body><p>Authorization complete. You can close this tab.</p></body></html>");
46
+ server.close();
47
+ clearTimeout(timeout);
48
+ if (error) {
49
+ reject(new Error(`OAuth error: ${error}`));
50
+ return;
51
+ }
52
+ if (receivedState !== state) {
53
+ reject(new Error("OAuth state mismatch"));
54
+ return;
55
+ }
56
+ if (!receivedCode) {
57
+ reject(new Error("OAuth callback missing code"));
58
+ return;
59
+ }
60
+ resolve(receivedCode);
61
+ });
62
+ server.listen(port, "127.0.0.1", () => {
63
+ console.error(`[greenflags-mcp] Opening browser for OAuth authorization...`);
64
+ void open(authorizeUrl.toString());
65
+ });
66
+ server.on("error", (err) => {
67
+ clearTimeout(timeout);
68
+ if (err.code === "EADDRINUSE") {
69
+ reject(new Error(`[greenflags-mcp] Port ${port} is already in use.\n` +
70
+ `Close the app using that port and try again.\n` +
71
+ `To find it: lsof -i :${port}`));
72
+ }
73
+ else {
74
+ reject(err);
75
+ }
76
+ });
77
+ });
78
+ const tokenRes = await axios.post(`${baseUrl}/oauth/token`, {
79
+ grant_type: "authorization_code",
80
+ code,
81
+ redirect_uri: redirectUri,
82
+ client_id: MCP_CLIENT_ID,
83
+ code_verifier: verifier,
84
+ }, { headers: { "content-type": "application/json" } });
85
+ const tokens = {
86
+ access_token: tokenRes.data.access_token,
87
+ expires_at: Date.now() + tokenRes.data.expires_in * 1000,
88
+ };
89
+ await saveTokens(tokens);
90
+ return tokens;
91
+ }
92
+ export async function getValidToken(baseUrl = GREENFLAGS_BASE_URL) {
93
+ const stored = await loadTokens();
94
+ const fiveMinutes = 5 * 60 * 1000;
95
+ if (stored && stored.expires_at - fiveMinutes > Date.now()) {
96
+ return stored.access_token;
97
+ }
98
+ const tokens = await authenticate(baseUrl);
99
+ return tokens.access_token;
100
+ }
@@ -0,0 +1,13 @@
1
+ export declare const GREENFLAGS_BASE_URL: string;
2
+ export declare const MCP_CLIENT_ID: string;
3
+ export declare const MCP_CALLBACK_PORT = 9876;
4
+ export declare const MCP_REDIRECT_URI = "http://localhost:9876/callback";
5
+ export declare const TOKEN_PATH: string;
6
+ export interface StoredTokens {
7
+ access_token: string;
8
+ expires_at: number;
9
+ }
10
+ export declare function saveTokens(tokens: StoredTokens): Promise<void>;
11
+ export declare function loadTokens(): Promise<StoredTokens | null>;
12
+ export declare function getDefaultProjectId(): string | undefined;
13
+ export declare function requireProjectId(arg?: unknown): string;
package/dist/config.js ADDED
@@ -0,0 +1,37 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { mkdir, readFile, writeFile } from "fs/promises";
4
+ import { MissingProjectIdError } from "./errors.js";
5
+ export const GREENFLAGS_BASE_URL = process.env["GREENFLAGS_URL"] ?? "http://localhost:8787";
6
+ // client_id of the OAuth client registered in GreenFlags (Developer Apps).
7
+ // The admin UI generates a random UUID on registration, so this must be
8
+ // configurable. Defaults to the seeded id for the manual-seed path.
9
+ export const MCP_CLIENT_ID = process.env["GREENFLAGS_CLIENT_ID"] ?? "greenflags-mcp-client";
10
+ export const MCP_CALLBACK_PORT = 9876;
11
+ export const MCP_REDIRECT_URI = `http://localhost:${MCP_CALLBACK_PORT}/callback`;
12
+ export const TOKEN_PATH = join(homedir(), ".greenflags", "tokens.json");
13
+ export async function saveTokens(tokens) {
14
+ await mkdir(join(homedir(), ".greenflags"), { recursive: true });
15
+ await writeFile(TOKEN_PATH, JSON.stringify(tokens, null, 2), "utf-8");
16
+ }
17
+ export async function loadTokens() {
18
+ try {
19
+ const raw = await readFile(TOKEN_PATH, "utf-8");
20
+ return JSON.parse(raw);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ export function getDefaultProjectId() {
27
+ const raw = process.env["GREENFLAGS_PROJECT_ID"];
28
+ return raw && raw.trim() !== "" ? raw.trim() : undefined;
29
+ }
30
+ export function requireProjectId(arg) {
31
+ if (typeof arg === "string" && arg.trim() !== "")
32
+ return arg.trim();
33
+ const fallback = getDefaultProjectId();
34
+ if (fallback)
35
+ return fallback;
36
+ throw new MissingProjectIdError();
37
+ }
@@ -0,0 +1,12 @@
1
+ export declare class ApiError extends Error {
2
+ code: string;
3
+ status: number;
4
+ details?: unknown | undefined;
5
+ constructor(code: string, message: string, status: number, details?: unknown | undefined);
6
+ }
7
+ export declare class MissingProjectIdError extends Error {
8
+ constructor();
9
+ }
10
+ export declare class SensitiveConfirmRequiredError extends Error {
11
+ constructor(envSlug: string);
12
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,24 @@
1
+ export class ApiError extends Error {
2
+ code;
3
+ status;
4
+ details;
5
+ constructor(code, message, status, details) {
6
+ super(message);
7
+ this.code = code;
8
+ this.status = status;
9
+ this.details = details;
10
+ this.name = "ApiError";
11
+ }
12
+ }
13
+ export class MissingProjectIdError extends Error {
14
+ constructor() {
15
+ super("project_id is required. Pass project_id (or project) on the tool call, or set GREENFLAGS_PROJECT_ID in the MCP server environment.");
16
+ this.name = "MissingProjectIdError";
17
+ }
18
+ }
19
+ export class SensitiveConfirmRequiredError extends Error {
20
+ constructor(envSlug) {
21
+ super(`Environment '${envSlug}' is marked sensitive. Re-call this tool with confirm: true to apply.`);
22
+ this.name = "SensitiveConfirmRequiredError";
23
+ }
24
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { errorResult } from "./result.js";
6
+ import * as listProjects from "./tools/listProjects.js";
7
+ import * as listFlags from "./tools/listFlags.js";
8
+ import * as getFlag from "./tools/getFlag.js";
9
+ import * as createFlag from "./tools/createFlag.js";
10
+ import * as updateFlag from "./tools/updateFlag.js";
11
+ import * as setFlagValue from "./tools/setFlagValue.js";
12
+ import * as archiveFlag from "./tools/archiveFlag.js";
13
+ import * as listEnvironments from "./tools/listEnvironments.js";
14
+ import * as getFlagValues from "./tools/getFlagValues.js";
15
+ const tools = [
16
+ listProjects,
17
+ listFlags,
18
+ getFlag,
19
+ listEnvironments,
20
+ getFlagValues,
21
+ createFlag,
22
+ updateFlag,
23
+ setFlagValue,
24
+ archiveFlag,
25
+ ];
26
+ const server = new Server({ name: "greenflags", version: "0.2.0" }, { capabilities: { tools: {} } });
27
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
28
+ tools: tools.map((t) => t.toolDefinition),
29
+ }));
30
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
31
+ const { name, arguments: args } = request.params;
32
+ const tool = tools.find((t) => t.toolDefinition.name === name);
33
+ if (!tool) {
34
+ return {
35
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
36
+ isError: true,
37
+ };
38
+ }
39
+ try {
40
+ const result = await tool.handler(args);
41
+ if (result &&
42
+ typeof result === "object" &&
43
+ "content" in result) {
44
+ return result;
45
+ }
46
+ return {
47
+ content: [{ type: "text", text: String(result) }],
48
+ };
49
+ }
50
+ catch (err) {
51
+ return errorResult(err);
52
+ }
53
+ });
54
+ const transport = new StdioServerTransport();
55
+ await server.connect(transport);
@@ -0,0 +1,22 @@
1
+ export interface FlagRecord {
2
+ id: string;
3
+ key: string;
4
+ name: string;
5
+ type: string;
6
+ status: string;
7
+ description?: string | null;
8
+ }
9
+ export interface EnvironmentRecord {
10
+ id: string;
11
+ name: string;
12
+ slug: string;
13
+ is_sensitive?: boolean;
14
+ }
15
+ export declare function resolveFlagId(projectId: string, flagOrKey: string): Promise<string>;
16
+ export declare function resolveFlag(projectId: string, flagOrKey: string): Promise<FlagRecord>;
17
+ export declare function resolveEnvironmentId(projectId: string, envOrSlug: string): Promise<string>;
18
+ export declare function resolveEnvironment(projectId: string, envOrSlug: string): Promise<EnvironmentRecord>;
19
+ export declare function listAllFlags(projectId: string): Promise<FlagRecord[]>;
20
+ export declare function listAllEnvironments(projectId: string): Promise<EnvironmentRecord[]>;
21
+ export declare function invalidateFlagCache(projectId: string): void;
22
+ export declare function invalidateEnvironmentCache(projectId: string): void;
@@ -0,0 +1,92 @@
1
+ import { callApi } from "./api.js";
2
+ import { ApiError } from "./errors.js";
3
+ const TTL_MS = 60_000;
4
+ const flagCache = new Map();
5
+ const envCache = new Map();
6
+ function fresh(entry) {
7
+ if (!entry)
8
+ return undefined;
9
+ if (Date.now() - entry.at > TTL_MS)
10
+ return undefined;
11
+ return entry;
12
+ }
13
+ async function loadFlags(projectId) {
14
+ const data = await callApi(`/admin/projects/${encodeURIComponent(projectId)}/flags`);
15
+ const byId = new Map();
16
+ const byAlt = new Map();
17
+ for (const f of data.flags) {
18
+ byId.set(f.id, f);
19
+ byAlt.set(f.key, f);
20
+ }
21
+ const entry = { at: Date.now(), byId, byAlt };
22
+ flagCache.set(projectId, entry);
23
+ return entry;
24
+ }
25
+ async function loadEnvironments(projectId) {
26
+ const data = await callApi(`/admin/projects/${encodeURIComponent(projectId)}/environments`);
27
+ const byId = new Map();
28
+ const byAlt = new Map();
29
+ for (const e of data.environments) {
30
+ const rec = {
31
+ id: e.id,
32
+ name: e.name,
33
+ slug: e.slug,
34
+ is_sensitive: Boolean(e.isSensitive),
35
+ };
36
+ byId.set(e.id, rec);
37
+ byAlt.set(e.slug, rec);
38
+ }
39
+ const entry = {
40
+ at: Date.now(),
41
+ byId,
42
+ byAlt,
43
+ };
44
+ envCache.set(projectId, entry);
45
+ return entry;
46
+ }
47
+ export async function resolveFlagId(projectId, flagOrKey) {
48
+ const entry = fresh(flagCache.get(projectId)) ?? (await loadFlags(projectId));
49
+ if (entry.byId.has(flagOrKey))
50
+ return flagOrKey;
51
+ const hit = entry.byAlt.get(flagOrKey);
52
+ if (hit)
53
+ return hit.id;
54
+ throw new ApiError("FLAG_NOT_FOUND", `Flag '${flagOrKey}' not found in project '${projectId}'. Use list_flags to see available flags.`, 404);
55
+ }
56
+ export async function resolveFlag(projectId, flagOrKey) {
57
+ const entry = fresh(flagCache.get(projectId)) ?? (await loadFlags(projectId));
58
+ const hit = entry.byId.get(flagOrKey) ?? entry.byAlt.get(flagOrKey);
59
+ if (hit)
60
+ return hit;
61
+ throw new ApiError("FLAG_NOT_FOUND", `Flag '${flagOrKey}' not found in project '${projectId}'.`, 404);
62
+ }
63
+ export async function resolveEnvironmentId(projectId, envOrSlug) {
64
+ const entry = fresh(envCache.get(projectId)) ?? (await loadEnvironments(projectId));
65
+ if (entry.byId.has(envOrSlug))
66
+ return envOrSlug;
67
+ const hit = entry.byAlt.get(envOrSlug);
68
+ if (hit)
69
+ return hit.id;
70
+ throw new ApiError("ENVIRONMENT_NOT_FOUND", `Environment '${envOrSlug}' not found in project '${projectId}'. Use list_environments to see available environments.`, 404);
71
+ }
72
+ export async function resolveEnvironment(projectId, envOrSlug) {
73
+ const entry = fresh(envCache.get(projectId)) ?? (await loadEnvironments(projectId));
74
+ const hit = entry.byId.get(envOrSlug) ?? entry.byAlt.get(envOrSlug);
75
+ if (hit)
76
+ return hit;
77
+ throw new ApiError("ENVIRONMENT_NOT_FOUND", `Environment '${envOrSlug}' not found in project '${projectId}'.`, 404);
78
+ }
79
+ export async function listAllFlags(projectId) {
80
+ const entry = fresh(flagCache.get(projectId)) ?? (await loadFlags(projectId));
81
+ return [...entry.byId.values()];
82
+ }
83
+ export async function listAllEnvironments(projectId) {
84
+ const entry = fresh(envCache.get(projectId)) ?? (await loadEnvironments(projectId));
85
+ return [...entry.byId.values()];
86
+ }
87
+ export function invalidateFlagCache(projectId) {
88
+ flagCache.delete(projectId);
89
+ }
90
+ export function invalidateEnvironmentCache(projectId) {
91
+ envCache.delete(projectId);
92
+ }
@@ -0,0 +1,10 @@
1
+ export interface McpToolResult {
2
+ content: {
3
+ type: "text";
4
+ text: string;
5
+ }[];
6
+ structuredContent?: Record<string, unknown>;
7
+ isError?: boolean;
8
+ }
9
+ export declare function okResult(structured: Record<string, unknown>, text: string): McpToolResult;
10
+ export declare function errorResult(err: unknown): McpToolResult;
package/dist/result.js ADDED
@@ -0,0 +1,37 @@
1
+ import { ApiError, MissingProjectIdError, SensitiveConfirmRequiredError, } from "./errors.js";
2
+ export function okResult(structured, text) {
3
+ return {
4
+ content: [{ type: "text", text }],
5
+ structuredContent: structured,
6
+ };
7
+ }
8
+ export function errorResult(err) {
9
+ if (err instanceof ApiError) {
10
+ return {
11
+ content: [{ type: "text", text: `${err.code}: ${err.message}` }],
12
+ structuredContent: {
13
+ error: {
14
+ code: err.code,
15
+ message: err.message,
16
+ status: err.status,
17
+ details: err.details ?? null,
18
+ },
19
+ },
20
+ isError: true,
21
+ };
22
+ }
23
+ if (err instanceof MissingProjectIdError ||
24
+ err instanceof SensitiveConfirmRequiredError) {
25
+ return {
26
+ content: [{ type: "text", text: err.message }],
27
+ structuredContent: { error: { code: err.name, message: err.message } },
28
+ isError: true,
29
+ };
30
+ }
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ return {
33
+ content: [{ type: "text", text: `Error: ${message}` }],
34
+ structuredContent: { error: { code: "UNKNOWN", message } },
35
+ isError: true,
36
+ };
37
+ }