@zvndev/circular-mcp 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -84,9 +84,25 @@ pulling at the same moment can pick another candidate if a reservation loses.
84
84
 
85
85
  ## Authentication
86
86
 
87
- Identical to the CLI. Provide a **team API key** (`circ_tk_…`) plus the
88
- workspace and team ids, via environment variables (MCP clients inject these
89
- through the server's `env` block):
87
+ Identical to the CLI. The normal path is managed browser sign-in:
88
+
89
+ ```bash
90
+ npx -y @zvndev/circular-cli login --runtime codex
91
+ ```
92
+
93
+ That command opens Circular, asks the signed-in human to approve a named agent,
94
+ then stores the managed connection locally in `~/.circular/connections.json`.
95
+ No API key has to be copied into an agent prompt or MCP config.
96
+
97
+ For long-running MCP processes, pin the connection id returned by `login`:
98
+
99
+ ```
100
+ CIRCULAR_CONNECTION_ID=key_or_connection_id_from_login
101
+ ```
102
+
103
+ Owners/admins can still provide an explicit **team API key** (`circ_tk_…`) for
104
+ ongoing workspace agents or legacy automation. MCP clients inject these through
105
+ the server's `env` block:
90
106
 
91
107
  ```
92
108
  CIRCULAR_API_KEY=circ_tk_xxx
@@ -95,7 +111,9 @@ CIRCULAR_TEAM_ID=team_xxx
95
111
  CIRCULAR_BASE_URL=https://gocircular.dev # optional; default
96
112
  ```
97
113
 
98
- `~/.circular/config.json` also works as a fallback.
114
+ `~/.circular/config.json` also works as a fallback for explicit keys. An
115
+ explicit key always wins over managed login; a Desktop-bound agent identity does
116
+ not borrow a managed or global fallback credential.
99
117
 
100
118
  ### Minting a team API key
101
119
 
@@ -113,9 +131,7 @@ below with `node /ABS/PATH/mcp/bin/circular-mcp.mjs`.
113
131
 
114
132
  ```bash
115
133
  claude mcp add circular \
116
- -e CIRCULAR_API_KEY=circ_tk_xxx \
117
- -e CIRCULAR_WORKSPACE_ID=ws_xxx \
118
- -e CIRCULAR_TEAM_ID=team_xxx \
134
+ -e CIRCULAR_CONNECTION_ID=key_or_connection_id_from_login \
119
135
  -- npx -y @zvndev/circular-mcp
120
136
  ```
121
137
 
@@ -131,9 +147,7 @@ Via the CLI:
131
147
 
132
148
  ```bash
133
149
  codex mcp add circular \
134
- --env CIRCULAR_API_KEY=circ_tk_xxx \
135
- --env CIRCULAR_WORKSPACE_ID=ws_xxx \
136
- --env CIRCULAR_TEAM_ID=team_xxx \
150
+ --env CIRCULAR_CONNECTION_ID=key_or_connection_id_from_login \
137
151
  -- npx -y @zvndev/circular-mcp
138
152
  ```
139
153
 
@@ -146,6 +160,12 @@ args = ["-y", "@zvndev/circular-mcp"]
146
160
  env = { CIRCULAR_API_KEY = "circ_tk_xxx", CIRCULAR_WORKSPACE_ID = "ws_xxx", CIRCULAR_TEAM_ID = "team_xxx" }
147
161
  ```
148
162
 
163
+ For a managed connection, use:
164
+
165
+ ```toml
166
+ env = { CIRCULAR_CONNECTION_ID = "key_or_connection_id_from_login" }
167
+ ```
168
+
149
169
  Verify with `codex mcp list` / `codex mcp get circular`.
150
170
 
151
171
  ### Cursor
@@ -10,10 +10,10 @@
10
10
  * Transport: MCP stdio (newline-delimited JSON-RPC 2.0). Protocol messages go on
11
11
  * stdin/stdout; everything human-facing goes to stderr.
12
12
  *
13
- * Auth is identical to the CLI: CIRCULAR_API_KEY (a team key, circ_tk_…),
14
- * CIRCULAR_WORKSPACE_ID, CIRCULAR_TEAM_ID (and optional CIRCULAR_BASE_URL), or
15
- * ~/.circular/config.json. MCP clients typically inject these via the server's
16
- * env block (e.g. `claude mcp add ... -e CIRCULAR_API_KEY=…`).
13
+ * Auth is identical to the CLI: a managed browser login from
14
+ * ~/.circular/connections.json, optionally pinned with CIRCULAR_CONNECTION_ID,
15
+ * or an explicit CIRCULAR_API_KEY plus workspace/team ids for legacy/service
16
+ * agents.
17
17
  */
18
18
  import { loadConfig } from "../lib/vendor/config.mjs";
19
19
  import { dispatch, SERVER_INFO } from "../lib/server.mjs";
package/lib/server.mjs CHANGED
@@ -21,7 +21,7 @@ const SUPPORTED_PROTOCOL_VERSIONS = new Set([
21
21
  "2024-11-05",
22
22
  ]);
23
23
 
24
- export const SERVER_INFO = { name: "circular-mcp", version: "0.1.1" };
24
+ export const SERVER_INFO = { name: "circular-mcp", version: "0.1.3" };
25
25
 
26
26
  /**
27
27
  * The briefing every client sees on connect. This is the only onboarding an
@@ -1,28 +1,12 @@
1
- /**
2
- * VENDORED from cli/lib/client.mjs. Do not edit here.
3
- *
4
- * The MCP server is published as a standalone package and advertises itself as
5
- * dependency-free, which is the whole point of `npx -y @zvndev/circular-mcp`:
6
- * nothing to resolve, nothing to install. It used to import this straight out
7
- * of `../../cli/lib`, a path that exists in the repo and in no published
8
- * tarball, so `npm publish` would have produced a package that crashed on its
9
- * first import.
10
- *
11
- * Everything below this header is a byte-for-byte copy of the CLI's file, and
12
- * test/package.test.mjs fails if the two ever drift.
13
- */
14
-
15
- /**
16
- * Thin HTTP client for the Circular Agent API. Authenticates with the team API
17
- * key via the Authorization: Bearer header. Returns parsed JSON; throws an
18
- * ApiError (with status + body) on non-2xx so the CLI can print + exit non-zero.
19
- */
20
1
  /**
21
2
  * Thin HTTP client for the Circular Agent API. Authenticates with the team API
22
3
  * key via the Authorization: Bearer header. Returns parsed JSON; throws an
23
4
  * ApiError (with status + body) on non-2xx so the CLI can print + exit non-zero.
24
5
  */
25
6
  import { readConfigFile } from "./config.mjs";
7
+ import { readConnectionsFile, selectConnection } from "./connections.mjs";
8
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
9
+
26
10
  export class ApiError extends Error {
27
11
  constructor(message, status, body) {
28
12
  super(message);
@@ -33,12 +17,18 @@ export class ApiError extends Error {
33
17
  }
34
18
 
35
19
  export function teamBase(config) {
36
- if (!config.apiKey) throw new Error("Missing API key. Set CIRCULAR_API_KEY.");
20
+ if (!config.apiKey) throw new Error("Not signed in. Run `circular login` or set CIRCULAR_API_KEY.");
37
21
  if (!config.workspaceId) throw new Error("Missing workspace. Set CIRCULAR_WORKSPACE_ID or --workspace.");
38
22
  if (!config.teamId) throw new Error("Missing team. Set CIRCULAR_TEAM_ID or --team.");
39
23
  return `${config.baseUrl}/api/workspaces/${config.workspaceId}/teams/${config.teamId}`;
40
24
  }
41
25
 
26
+ export function workspaceBase(config) {
27
+ if (!config.apiKey) throw new Error("Not signed in. Run `circular login` or set CIRCULAR_API_KEY.");
28
+ if (!config.workspaceId) throw new Error("Missing workspace. Set CIRCULAR_WORKSPACE_ID or --workspace.");
29
+ return `${config.baseUrl}/api/workspaces/${config.workspaceId}`;
30
+ }
31
+
42
32
  /**
43
33
  * The key on disk, if it is not the one that was just refused.
44
34
  *
@@ -68,14 +58,45 @@ function rotatedKey(usedKey, readFile = readConfigFile) {
68
58
  }
69
59
 
70
60
  async function send(config, method, url, body) {
71
- return fetch(url, {
72
- method,
73
- headers: {
74
- Authorization: `Bearer ${config.apiKey}`,
75
- ...(body ? { "Content-Type": "application/json" } : {}),
76
- },
77
- ...(body ? { body: JSON.stringify(body) } : {}),
78
- });
61
+ const controller = new AbortController();
62
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
63
+ try {
64
+ return await fetch(url, {
65
+ method,
66
+ redirect: "manual",
67
+ signal: controller.signal,
68
+ headers: {
69
+ Authorization: `Bearer ${config.apiKey}`,
70
+ ...(body ? { "Content-Type": "application/json" } : {}),
71
+ },
72
+ ...(body ? { body: JSON.stringify(body) } : {}),
73
+ });
74
+ } finally {
75
+ clearTimeout(timeout);
76
+ }
77
+ }
78
+
79
+ export function refreshManagedConnection(config, readStore = readConnectionsFile) {
80
+ if (!config.managedConnectionId) return config;
81
+ const store = readStore();
82
+ const connection = selectConnection(store, config.managedConnectionId);
83
+ if (!connection) {
84
+ throw new Error(`Circular connection ${config.managedConnectionId} is not saved locally. The MCP process is pinned to this connection and will not fall back to another account.`);
85
+ }
86
+ if (stripTrailingSlash(connection.baseUrl) !== stripTrailingSlash(config.baseUrl)) {
87
+ throw new Error(`Circular connection ${config.managedConnectionId} no longer matches the pinned API origin.`);
88
+ }
89
+ if (connection.workspaceId !== config.workspaceId) {
90
+ throw new Error(`Circular connection ${config.managedConnectionId} no longer matches the pinned workspace.`);
91
+ }
92
+ if (config.agentParticipantId && connection.agentParticipantId !== config.agentParticipantId) {
93
+ throw new Error(`Circular connection ${config.managedConnectionId} no longer matches the pinned agent identity.`);
94
+ }
95
+ if (config.authorizingUserId && connection.authorizingUserId !== config.authorizingUserId) {
96
+ throw new Error(`Circular connection ${config.managedConnectionId} no longer matches the pinned authorizing user.`);
97
+ }
98
+ config.apiKey = connection.token;
99
+ return config;
79
100
  }
80
101
 
81
102
  /**
@@ -89,7 +110,9 @@ export async function apiRequest(
89
110
  path,
90
111
  { query, body } = {},
91
112
  readFile = readConfigFile,
113
+ readConnectionStore = readConnectionsFile,
92
114
  ) {
115
+ refreshManagedConnection(config, readConnectionStore);
93
116
  const url = new URL(`${teamBase(config)}${path}`);
94
117
  if (query) {
95
118
  for (const [key, value] of Object.entries(query)) {
@@ -99,11 +122,69 @@ export async function apiRequest(
99
122
 
100
123
  let response = await send(config, method, url, body);
101
124
 
102
- // One retry, and only when the file genuinely holds a different key. The
103
- // config object is mutated so the rest of the session uses the live key too:
125
+ // Legacy unbound sessions get one retry when the file holds a different key.
126
+ // Bound sessions cannot prove that a global key represents the same agent
127
+ // and human, so they must reconnect instead of silently changing identity.
128
+ // The config object is mutated so the session uses the live key afterward:
104
129
  // recovering one request and leaving the next twenty to fail would be worse
105
130
  // than not recovering at all, because the failure would look intermittent.
106
- if (response.status === 401) {
131
+ if (response.status === 401 && !config.agentParticipantId && !config.managedConnectionId) {
132
+ const rotated = rotatedKey(config.apiKey, readFile);
133
+ if (rotated) {
134
+ config.apiKey = rotated;
135
+ response = await send(config, method, url, body);
136
+ }
137
+ }
138
+
139
+ const text = await response.text();
140
+ let parsed;
141
+ try {
142
+ parsed = text ? JSON.parse(text) : null;
143
+ } catch {
144
+ parsed = text;
145
+ }
146
+
147
+ if (!response.ok) {
148
+ if (response.status === 401 && config.agentParticipantId) {
149
+ throw new ApiError(
150
+ `${method} ${path} failed (401): This agent session credential expired or was revoked. Reconnect the session in Circular to authorize the same agent again; the global account key was not used.`,
151
+ response.status,
152
+ parsed,
153
+ );
154
+ }
155
+ if (response.status === 401 && config.managedConnectionId) {
156
+ throw new ApiError(
157
+ `${method} ${path} failed (401): This managed Circular connection expired or was revoked. Run \`circular login\` again and update CIRCULAR_CONNECTION_ID to the new connection id; no fallback API key was used.`,
158
+ response.status,
159
+ parsed,
160
+ );
161
+ }
162
+ const detail =
163
+ parsed && typeof parsed === "object" && parsed.error ? parsed.error : response.statusText;
164
+ throw new ApiError(`${method} ${path} failed (${response.status}): ${detail}`, response.status, parsed);
165
+ }
166
+
167
+ return parsed;
168
+ }
169
+
170
+ export async function workspaceRequest(
171
+ config,
172
+ method,
173
+ path,
174
+ { query, body } = {},
175
+ readFile = readConfigFile,
176
+ readConnectionStore = readConnectionsFile,
177
+ ) {
178
+ refreshManagedConnection(config, readConnectionStore);
179
+ const url = new URL(`${workspaceBase(config)}${path}`);
180
+ if (query) {
181
+ for (const [key, value] of Object.entries(query)) {
182
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
183
+ }
184
+ }
185
+
186
+ let response = await send(config, method, url, body);
187
+ if (response.status === 401 && !config.agentParticipantId && !config.managedConnectionId) {
107
188
  const rotated = rotatedKey(config.apiKey, readFile);
108
189
  if (rotated) {
109
190
  config.apiKey = rotated;
@@ -120,6 +201,20 @@ export async function apiRequest(
120
201
  }
121
202
 
122
203
  if (!response.ok) {
204
+ if (response.status === 401 && config.agentParticipantId) {
205
+ throw new ApiError(
206
+ `${method} ${path} failed (401): This agent session credential expired or was revoked. Reconnect the session in Circular to authorize the same agent again; the global account key was not used.`,
207
+ response.status,
208
+ parsed,
209
+ );
210
+ }
211
+ if (response.status === 401 && config.managedConnectionId) {
212
+ throw new ApiError(
213
+ `${method} ${path} failed (401): This managed Circular connection expired or was revoked. Run \`circular login\` again and update CIRCULAR_CONNECTION_ID to the new connection id; no fallback API key was used.`,
214
+ response.status,
215
+ parsed,
216
+ );
217
+ }
123
218
  const detail =
124
219
  parsed && typeof parsed === "object" && parsed.error ? parsed.error : response.statusText;
125
220
  throw new ApiError(`${method} ${path} failed (${response.status}): ${detail}`, response.status, parsed);
@@ -127,3 +222,7 @@ export async function apiRequest(
127
222
 
128
223
  return parsed;
129
224
  }
225
+
226
+ function stripTrailingSlash(url) {
227
+ return typeof url === "string" ? url.replace(/\/+$/, "") : url;
228
+ }
@@ -1,17 +1,3 @@
1
- /**
2
- * VENDORED from cli/lib/config.mjs. Do not edit here.
3
- *
4
- * The MCP server is published as a standalone package and advertises itself as
5
- * dependency-free, which is the whole point of `npx -y @zvndev/circular-mcp`:
6
- * nothing to resolve, nothing to install. It used to import this straight out
7
- * of `../../cli/lib`, a path that exists in the repo and in no published
8
- * tarball, so `npm publish` would have produced a package that crashed on its
9
- * first import.
10
- *
11
- * Everything below this header is a byte-for-byte copy of the CLI's file, and
12
- * test/package.test.mjs fails if the two ever drift.
13
- */
14
-
15
1
  /**
16
2
  * Config resolution for the Circular CLI.
17
3
  *
@@ -22,6 +8,7 @@
22
8
  import { readFileSync } from "node:fs";
23
9
  import { homedir } from "node:os";
24
10
  import { join } from "node:path";
11
+ import { findConnection, hasConnectionId, readConnectionsFile, tryReadConnectionsFile } from "./connections.mjs";
25
12
 
26
13
  export const DEFAULT_BASE_URL = "https://gocircular.dev";
27
14
 
@@ -41,26 +28,95 @@ export function readConfigFile(path = configFilePath()) {
41
28
  * Merge flags, env, and file into the effective config. `flags` are the parsed
42
29
  * CLI flags; `env` defaults to process.env; `file` is the parsed config file.
43
30
  */
44
- export function resolveConfig(flags = {}, env = process.env, file = {}) {
31
+ export function resolveConfig(flags = {}, env = process.env, file = {}, connectionStore = {}) {
32
+ const agentParticipantId = env.CIRCULAR_AGENT_PARTICIPANT_ID?.trim();
33
+ const authorizingUserId = env.CIRCULAR_AUTHORIZING_USER_ID?.trim();
45
34
  const pick = (flagKey, envKey, fileKey, fallback) => {
46
35
  if (flags[flagKey] !== undefined && flags[flagKey] !== true) return flags[flagKey];
47
36
  if (env[envKey]) return env[envKey];
48
- if (file[fileKey]) return file[fileKey];
37
+ if (file[fileKey] && !(agentParticipantId && fileKey === "apiKey")) return file[fileKey];
49
38
  return fallback;
50
39
  };
51
40
 
41
+ const explicitRuntimeApiKey = flags["api-key"] !== undefined && flags["api-key"] !== true
42
+ ? flags["api-key"]
43
+ : env.CIRCULAR_API_KEY || undefined;
44
+ const legacyFileApiKey = !agentParticipantId ? file.apiKey : undefined;
45
+ const requestedBaseUrlOverride = flags["base-url"] !== undefined && flags["base-url"] !== true
46
+ ? flags["base-url"]
47
+ : env.CIRCULAR_BASE_URL || undefined;
48
+ const requestedWorkspaceOverride = flags.workspace !== undefined && flags.workspace !== true
49
+ ? flags.workspace
50
+ : env.CIRCULAR_WORKSPACE_ID || undefined;
51
+ const requestedTeamOverride = flags.team !== undefined && flags.team !== true
52
+ ? flags.team
53
+ : env.CIRCULAR_TEAM_ID || undefined;
54
+ const requestedBaseUrl = stripTrailingSlash(
55
+ pick("base-url", "CIRCULAR_BASE_URL", "baseUrl", undefined)
56
+ );
57
+ const requestedWorkspaceId = pick("workspace", "CIRCULAR_WORKSPACE_ID", "workspaceId", undefined);
58
+ const requestedTeamId = pick("team", "CIRCULAR_TEAM_ID", "teamId", undefined);
59
+ const connectionId = flags.connection !== undefined && flags.connection !== true
60
+ ? flags.connection
61
+ : env.CIRCULAR_CONNECTION_ID || file.connectionId || undefined;
62
+ const managedConnection = !explicitRuntimeApiKey && !agentParticipantId
63
+ ? findConnection(connectionStore, {
64
+ id: connectionId,
65
+ baseUrl: stripTrailingSlash(requestedBaseUrlOverride),
66
+ workspaceId: requestedWorkspaceOverride,
67
+ })
68
+ : null;
69
+
70
+ const hasManagedConnectionFilter = Boolean(requestedBaseUrlOverride || requestedWorkspaceOverride);
71
+ const hasSavedConnections = Array.isArray(connectionStore?.connections) && connectionStore.connections.length > 0;
72
+ if ((connectionId || (hasManagedConnectionFilter && hasSavedConnections)) && !managedConnection && !explicitRuntimeApiKey && !agentParticipantId) {
73
+ if (!connectionId) {
74
+ throw new Error("No saved Circular connection matches the requested origin/workspace/team. Run circular status or circular login.");
75
+ }
76
+ const reason = hasConnectionId(connectionStore, connectionId)
77
+ ? "but it does not match the requested origin/workspace/team"
78
+ : "and it is not saved locally";
79
+ throw new Error(`Circular connection ${connectionId} was requested ${reason}. Run circular status or circular login.`);
80
+ }
81
+
82
+ if (managedConnection) {
83
+ return {
84
+ apiKey: managedConnection.token,
85
+ baseUrl: stripTrailingSlash(managedConnection.baseUrl),
86
+ workspaceId: managedConnection.workspaceId,
87
+ teamId: requestedTeamOverride ?? managedConnection.teamId,
88
+ managedConnectionId: managedConnection.id,
89
+ ...(managedConnection.agentParticipantId ? { agentParticipantId: managedConnection.agentParticipantId } : {}),
90
+ ...(managedConnection.authorizingUserId ? { authorizingUserId: managedConnection.authorizingUserId } : {}),
91
+ };
92
+ }
93
+
52
94
  return {
53
- apiKey: pick("api-key", "CIRCULAR_API_KEY", "apiKey", undefined),
95
+ // A bound session cannot borrow the operator's global credential, including
96
+ // when its delegated key is missing rather than rejected by the server.
97
+ apiKey: explicitRuntimeApiKey ?? legacyFileApiKey,
54
98
  baseUrl: stripTrailingSlash(
55
99
  pick("base-url", "CIRCULAR_BASE_URL", "baseUrl", DEFAULT_BASE_URL)
56
100
  ),
57
- workspaceId: pick("workspace", "CIRCULAR_WORKSPACE_ID", "workspaceId", undefined),
58
- teamId: pick("team", "CIRCULAR_TEAM_ID", "teamId", undefined),
101
+ workspaceId: requestedWorkspaceId,
102
+ teamId: requestedTeamId,
103
+ ...(agentParticipantId ? { agentParticipantId, ...(authorizingUserId ? { authorizingUserId } : {}) } : {}),
59
104
  };
60
105
  }
61
106
 
62
- export function loadConfig(flags = {}) {
63
- return resolveConfig(flags, process.env, readConfigFile());
107
+ export function loadConfig(flags = {}, options = {}) {
108
+ const file = readConfigFile();
109
+ if (options.allowMissingManagedConnection) {
110
+ return resolveConfig(flags, process.env, file, { version: 1, currentConnectionId: null, connections: [] });
111
+ }
112
+ const hasExplicitRuntimeApiKey = flags["api-key"] !== undefined && flags["api-key"] !== true
113
+ ? true
114
+ : Boolean(process.env.CIRCULAR_API_KEY);
115
+ const hasBoundDesktopIdentity = Boolean(process.env.CIRCULAR_AGENT_PARTICIPANT_ID?.trim());
116
+ const connectionStore = hasExplicitRuntimeApiKey || hasBoundDesktopIdentity
117
+ ? tryReadConnectionsFile()
118
+ : readConnectionsFile();
119
+ return resolveConfig(flags, process.env, file, connectionStore);
64
120
  }
65
121
 
66
122
  function stripTrailingSlash(url) {
@@ -0,0 +1,149 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export function connectionsFilePath() {
6
+ if (process.env.CIRCULAR_CONNECTIONS_FILE) return process.env.CIRCULAR_CONNECTIONS_FILE;
7
+ return join(homedir(), ".circular", "connections.json");
8
+ }
9
+
10
+ export function isTrustedCircularOrigin(value) {
11
+ try {
12
+ const url = new URL(value);
13
+ if (url.protocol === "https:") return true;
14
+ return url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export function readConnectionsFile(path = connectionsFilePath()) {
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(readFileSync(path, "utf8"));
24
+ } catch (error) {
25
+ if (error && typeof error === "object" && error.code === "ENOENT") {
26
+ return normalizeStore({});
27
+ }
28
+ throw new Error(`Could not read Circular connections file at ${path}. Fix or move the corrupt file before writing a new managed connection.`);
29
+ }
30
+ assertValidStore(parsed, path);
31
+ return normalizeStore(parsed);
32
+ }
33
+
34
+ export function tryReadConnectionsFile(path = connectionsFilePath()) {
35
+ try {
36
+ return readConnectionsFile(path);
37
+ } catch {
38
+ return normalizeStore({});
39
+ }
40
+ }
41
+
42
+ export function writeConnectionsFile(store, path = connectionsFilePath()) {
43
+ assertValidStore(store, path);
44
+ const normalized = normalizeStore(store);
45
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
46
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
47
+ writeFileSync(tmp, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
48
+ chmodSync(tmp, 0o600);
49
+ renameSync(tmp, path);
50
+ try {
51
+ chmodSync(path, 0o600);
52
+ } catch {
53
+ // Best effort on non-POSIX filesystems.
54
+ }
55
+ return normalized;
56
+ }
57
+
58
+ export function normalizeStore(store) {
59
+ const connections = Array.isArray(store?.connections)
60
+ ? store.connections.filter(isConnection)
61
+ : [];
62
+ const currentConnectionId = typeof store?.currentConnectionId === "string"
63
+ && connections.some((connection) => connection.id === store.currentConnectionId)
64
+ ? store.currentConnectionId
65
+ : null;
66
+ return { version: 1, currentConnectionId, connections };
67
+ }
68
+
69
+ export function selectConnection(store, id) {
70
+ const normalized = normalizeStore(store);
71
+ const connectionId = id || normalized.currentConnectionId;
72
+ if (!connectionId) return null;
73
+ return normalized.connections.find((connection) => connection.id === connectionId) ?? null;
74
+ }
75
+
76
+ export function findConnection(store, { id, baseUrl, workspaceId, teamId } = {}) {
77
+ const normalized = normalizeStore(store);
78
+ const candidates = id
79
+ ? normalized.connections.filter((connection) => connection.id === id)
80
+ : normalized.connections.filter((connection) => connection.id === normalized.currentConnectionId);
81
+ return candidates.find((connection) =>
82
+ (!baseUrl || stripTrailingSlash(connection.baseUrl) === stripTrailingSlash(baseUrl)) &&
83
+ (!workspaceId || connection.workspaceId === workspaceId) &&
84
+ (!teamId || connection.teamId === teamId)
85
+ ) ?? null;
86
+ }
87
+
88
+ export function hasConnectionId(store, id) {
89
+ return normalizeStore(store).connections.some((connection) => connection.id === id);
90
+ }
91
+
92
+ export function upsertConnection(store, connection) {
93
+ if (!isConnection(connection)) throw new Error("Invalid Circular connection");
94
+ const normalized = normalizeStore(store);
95
+ const connections = [
96
+ connection,
97
+ ...normalized.connections.filter((entry) => entry.id !== connection.id),
98
+ ];
99
+ return { version: 1, currentConnectionId: connection.id, connections };
100
+ }
101
+
102
+ export function removeConnection(store, id) {
103
+ const normalized = normalizeStore(store);
104
+ const connections = normalized.connections.filter((entry) => entry.id !== id);
105
+ return {
106
+ version: 1,
107
+ currentConnectionId: normalized.currentConnectionId === id ? null : normalized.currentConnectionId,
108
+ connections,
109
+ };
110
+ }
111
+
112
+ export function publicConnectionRecord(connection) {
113
+ if (!connection) return null;
114
+ const { token: _token, ...safe } = connection;
115
+ return safe;
116
+ }
117
+
118
+ function stripTrailingSlash(url) {
119
+ return typeof url === "string" ? url.replace(/\/+$/, "") : url;
120
+ }
121
+
122
+ function assertValidStore(store, path) {
123
+ if (!store || typeof store !== "object" || Array.isArray(store)) {
124
+ throw new Error(`Invalid Circular connections file at ${path}. Expected a versioned connection store.`);
125
+ }
126
+ if (store.version !== 1 || !Array.isArray(store.connections)) {
127
+ throw new Error(`Invalid Circular connections file at ${path}. Expected version 1 with a connections array.`);
128
+ }
129
+ if (!(store.currentConnectionId === null || store.currentConnectionId === undefined || typeof store.currentConnectionId === "string")) {
130
+ throw new Error(`Invalid Circular connections file at ${path}. currentConnectionId must be a string or null.`);
131
+ }
132
+ const invalid = store.connections.find((connection) => !isConnection(connection));
133
+ if (invalid) {
134
+ throw new Error(`Invalid Circular connections file at ${path}. Connection entries must include id, name, trusted baseUrl, token, workspaceId and teamId.`);
135
+ }
136
+ }
137
+
138
+ function isConnection(value) {
139
+ return Boolean(
140
+ value &&
141
+ typeof value.id === "string" &&
142
+ typeof value.name === "string" &&
143
+ typeof value.baseUrl === "string" &&
144
+ isTrustedCircularOrigin(value.baseUrl) &&
145
+ typeof value.token === "string" &&
146
+ typeof value.workspaceId === "string" &&
147
+ typeof value.teamId === "string",
148
+ );
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/circular-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Dependency-free stdio MCP server for the Circular Agent API — plan work into Circular as tracked tasks and subtasks.",
5
5
  "keywords": [
6
6
  "circular",