@zvndev/circular-mcp 0.1.2 → 0.1.4

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
@@ -72,9 +72,10 @@ Steps come in three kinds:
72
72
  - **ACTION**: you do it, then tick it with `circular_complete_step` and real
73
73
  evidence in `proof` (test output, a diff summary, a link).
74
74
  - **REVIEW**: a human gate. `circular_complete_step` answers **403** for these,
75
- always: an agent may never sign off its own review. While the process review
76
- gate is parked, open REVIEW steps do not block `done`; leave them unticked and
77
- post proof/commentary instead.
75
+ always: an agent may never sign off its own review. Enrolled runner connections
76
+ cannot mark done with open stored REVIEW or AUTOMATION steps, even when paused.
77
+ Legacy callers retain the configured legacy process-gate policy (currently
78
+ off); an unticked review is never an agent approval. Post proof/commentary.
78
79
  - **AUTOMATION**: ticked by its own CI/GitHub signal, not by hand.
79
80
 
80
81
  `circular_get_next_work` deliberately **does not reserve** the issue it returns.
@@ -82,11 +83,51 @@ Reservations live in `circular_claim_issue_work`, keyed by a stable request id
82
83
  for safe retry after a lost response. Several candidates come back so two agents
83
84
  pulling at the same moment can pick another candidate if a reservation loses.
84
85
 
86
+ ## MCP tools versus an always-on computer
87
+
88
+ Adding this stdio connector makes tools available to an agent. It does not
89
+ start a provider, register a worker, or install a background service. Circular's
90
+ cloud durably queues assignments, mentions and workflow events. An explicitly
91
+ enrolled **foreground local runner** polls its addressed deliveries and launches
92
+ the installed Codex or Claude CLI on that computer, one job at a time. Its
93
+ connection and provider subscription stay private to that computer.
94
+
95
+ Use the [public connection docs](https://gocircular.dev/docs) after
96
+ browser login (or importing an admin-created workspace-agent connection), linking
97
+ the project repository, and connecting the local checkout. Keep `circular runner
98
+ start --connection CONNECTION_ID` running; an OS service is a separate explicit
99
+ operator setup, never an automatic side effect of installing MCP or Desktop.
100
+
101
+ `runner register --allow-circular-tools` explicitly permits unattended use of
102
+ the runner's generated Circular MCP tools. `--allow-write` separately permits
103
+ local code edits within the grant/profile ceiling. Neither flag grants access
104
+ to unrelated MCP servers or lets an agent approve human-only steps. A runner
105
+ works its already leased delivery rather than pulling unrelated `next-work`
106
+ candidates. Registration requires the explicit Circular tool opt-in to avoid
107
+ provider approval prompts stranding headless work. Use an interactive agent if
108
+ you do not want unattended tool calls.
109
+
85
110
  ## Authentication
86
111
 
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):
112
+ Identical to the CLI. The normal path is managed browser sign-in:
113
+
114
+ ```bash
115
+ npx -y @zvndev/circular-cli login --runtime codex
116
+ ```
117
+
118
+ That command opens Circular, asks the signed-in human to approve a named agent,
119
+ then stores the managed connection locally in `~/.circular/connections.json`.
120
+ No API key has to be copied into an agent prompt or MCP config.
121
+
122
+ For long-running MCP processes, pin the connection id returned by `login`:
123
+
124
+ ```
125
+ CIRCULAR_CONNECTION_ID=key_or_connection_id_from_login
126
+ ```
127
+
128
+ Owners/admins can still provide an explicit **team API key** (`circ_tk_…`) for
129
+ ongoing workspace agents or legacy automation. MCP clients inject these through
130
+ the server's `env` block:
90
131
 
91
132
  ```
92
133
  CIRCULAR_API_KEY=circ_tk_xxx
@@ -95,7 +136,9 @@ CIRCULAR_TEAM_ID=team_xxx
95
136
  CIRCULAR_BASE_URL=https://gocircular.dev # optional; default
96
137
  ```
97
138
 
98
- `~/.circular/config.json` also works as a fallback.
139
+ `~/.circular/config.json` also works as a fallback for explicit keys. An
140
+ explicit key always wins over managed login; a Desktop-bound agent identity does
141
+ not borrow a managed or global fallback credential.
99
142
 
100
143
  ### Minting a team API key
101
144
 
@@ -113,9 +156,7 @@ below with `node /ABS/PATH/mcp/bin/circular-mcp.mjs`.
113
156
 
114
157
  ```bash
115
158
  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 \
159
+ -e CIRCULAR_CONNECTION_ID=key_or_connection_id_from_login \
119
160
  -- npx -y @zvndev/circular-mcp
120
161
  ```
121
162
 
@@ -131,9 +172,7 @@ Via the CLI:
131
172
 
132
173
  ```bash
133
174
  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 \
175
+ --env CIRCULAR_CONNECTION_ID=key_or_connection_id_from_login \
137
176
  -- npx -y @zvndev/circular-mcp
138
177
  ```
139
178
 
@@ -146,6 +185,12 @@ args = ["-y", "@zvndev/circular-mcp"]
146
185
  env = { CIRCULAR_API_KEY = "circ_tk_xxx", CIRCULAR_WORKSPACE_ID = "ws_xxx", CIRCULAR_TEAM_ID = "team_xxx" }
147
186
  ```
148
187
 
188
+ For a managed connection, use:
189
+
190
+ ```toml
191
+ env = { CIRCULAR_CONNECTION_ID = "key_or_connection_id_from_login" }
192
+ ```
193
+
149
194
  Verify with `codex mcp list` / `codex mcp get circular`.
150
195
 
151
196
  ### 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,18 +21,20 @@ 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.2" };
24
+ export const SERVER_INFO = { name: "circular-mcp", version: "0.1.4" };
25
25
 
26
26
  /**
27
27
  * The briefing every client sees on connect. This is the only onboarding an
28
28
  * agent gets before it starts calling tools, so it states the model (pull-based,
29
- * Circular never executes anything), the loop, and the two absolute rules.
29
+ * cloud coordination versus explicitly enrolled local execution), the loop,
30
+ * and the two absolute rules.
30
31
  */
31
32
  export const INSTRUCTIONS = [
32
- "Circular is the source of truth for planning and task management. Circular is the brain; your agent is the hands. It never runs your agent and never executes work: you pull work, do it on your own machine, and write the result back.",
33
- "Work is PULLED, never pushed. Call circular_get_next_work to find out what to do and whether you are allowed to do it, and read each candidate's `situation.disposition` before acting. Only `ready_for_you` and `no_process` are yours to pick up.",
33
+ "Circular is the source of truth for planning and task management. Its cloud queues work and stores context and proof; it does not host agent execution or sell inference. Interactive agents pull work themselves. A separately enrolled foreground runner polls addressed deliveries and starts the installed Codex or Claude CLI on its own computer, using that computer's private, scoped connection and provider subscription.",
34
+ "MCP supplies tools; adding this connector does not start a worker or install an OS service. For interactive work, call circular_get_next_work and read each candidate's `situation.disposition`; only `ready_for_you` and `no_process` are yours to pick up. An enrolled runner already owns a specific delivery: stay on that task instead of pulling or claiming unrelated candidates.",
34
35
  AGENT_LOOP_SUMMARY,
35
- "Two rules are absolute. First, an API key can NEVER complete a REVIEW step; review is the human sign-off, refused for every api_key caller including the agent that did the work. Leave REVIEW steps unticked: they no longer hold the issue open, so do the ACTION steps, record your proof, and close the issue when the work is done. Second, never invent, reorder, or edit the step ladder; it comes from the team's Process. Record proof on the step it belongs to with circular_complete_step, using the stepId exactly as returned.",
36
+ "Two rules are absolute. First, an API key can NEVER complete a REVIEW step; review is the human sign-off, refused for every api_key caller including the agent that did the work. Enrolled runner agents cannot mark done while stored REVIEW or AUTOMATION steps remain open or unreadable, even when their computer is paused and the legacy process gate is disabled. Legacy callers still follow the server's configured completion policy; do not assume an open review always blocks them or that they may approve it. Post proof and leave human-only steps unticked. Second, never invent, reorder, or edit the step ladder; it comes from the team's Process. Record proof on the step it belongs to with circular_complete_step, using the stepId exactly as returned.",
37
+ "Runner opt-ins are separate: --allow-write permits local repository edits within the grant/profile ceiling; registration requires --allow-circular-tools to explicitly permit unattended use of the generated Circular MCP tools. Neither flag authorizes another account, repository, unrelated MCP server or human approval. A runner handles one job at a time and no background service is installed automatically.",
36
38
  "Do not create local Markdown TODO, PLAN, status, or handoff files as a parallel tracker. Use circular_plan_tasks for plans and circular_comment_issue for updates, handoffs, and narrative proof. Markdown is only for durable product documentation or an artifact the user explicitly requested.",
37
39
  ].join(" ");
38
40
 
package/lib/tools.mjs CHANGED
@@ -30,7 +30,9 @@ export const AGENT_LOOP_SUMMARY =
30
30
  "(6) circular_complete_step with real proof for each ACTION step you finish, " +
31
31
  "(7) circular_comment_issue for the narrative handoff, " +
32
32
  "(8) circular_release_issue_work_claim when you stop holding the lane, " +
33
- "(9) circular_update_issue to done once the work is finished.";
33
+ "(9) circular_update_issue to done only when the server's completion policy permits it; " +
34
+ "enrolled runner agents must wait for stored human REVIEW and AUTOMATION gates. " +
35
+ "This candidate-pull loop is for interactive agents; a runner stays on its already leased delivery.";
34
36
 
35
37
  /** Drop undefined keys so we never send `"priority": undefined`. */
36
38
  function prune(obj) {
@@ -109,8 +111,8 @@ export const TOOLS = [
109
111
  {
110
112
  name: "circular_get_next_work",
111
113
  description:
112
- "THE pull primitive: ask Circular what to work on. Circular never pushes work to you and never " +
113
- "runs your agent, so this is how work reaches you. " +
114
+ "The interactive pull primitive: ask Circular what to work on. An enrolled local runner " +
115
+ "instead polls addressed deliveries and starts its installed provider; it must stay on its leased task. " +
114
116
  "WHEN: at the start of every work cycle, and again after you finish an issue. " +
115
117
  "RETURNS: `actor` (who Circular thinks you are, including `canCompleteReviewSteps`), `limit`, " +
116
118
  "and `candidates`, highest priority first, with done, cancelled and blocked issues already " +
@@ -119,13 +121,14 @@ export const TOOLS = [
119
121
  "READ `situation.disposition` BEFORE ACTING. It is one of: " +
120
122
  "`ready_for_you` (the current step is an open ACTION step you may complete: proceed), " +
121
123
  "`waiting_on_human_review` (the current step is a REVIEW step no API key can tick: leave it " +
122
- "unticked, but it does not block the issue, so finish the work and close it), " +
124
+ "unticked and post a handoff; enrolled runner agents cannot close it before human review), " +
123
125
  "`waiting_on_automation` (an AUTOMATION step, ticked by its own signal, not by you: skip it), " +
124
126
  "`assigned_to_someone_else` (the current step names another person, team, or agent: skip it), " +
125
127
  "`process_complete` (every step ticked; it only needs closing), " +
126
128
  "`no_process` (no ladder: do the work, comment, set it to done), " +
127
129
  "`ladder_unreadable` (the stored steps cannot be parsed, so Circular refuses step edits on " +
128
- "this issue: do not try to repair the ladder, but the issue can still be closed). " +
130
+ "this issue: do not try to repair the ladder; enrolled runner agents cannot close it, " +
131
+ "while legacy callers follow the configured completion policy). " +
129
132
  "`situation.currentStep` gives the open step's id, kind, `assignment`, `assignedTo`, " +
130
133
  "`canComplete`, and a `refusal` reason when you may not complete it. " +
131
134
  "IT DOES NOT RESERVE the issue: several agents can be handed the same candidate. Reserve by " +
@@ -287,15 +290,15 @@ export const TOOLS = [
287
290
  name: "circular_update_issue",
288
291
  description:
289
292
  "Update an issue's status, priority, title, description, or assignee. This is also how you " +
290
- "CLAIM work: set " +
291
- "status to in_progress the moment you pick a candidate up, because circular_get_next_work does " +
292
- "not claim and another agent may be holding the same one. " +
293
+ "record progress, not reserve work: use circular_claim_issue_work for an interactive claim, " +
294
+ "then set status to in_progress. circular_get_next_work does not claim; a runner already holds its addressed delivery. " +
293
295
  "WHEN: in_progress on pickup; done once the whole ladder is finished. " +
294
296
  "REFUSES with 409: moving to done while any blocking issue is still open. That is the " +
295
297
  "dependency graph working, not an obstacle to route around: post your proof, leave a comment, " +
296
298
  "and pick up something that is not blocked. " +
297
- "An open REVIEW step no longer refuses done. It stays unticked, because no API key may " +
298
- "complete one, but it does not hold the issue open. " +
299
+ "Enrolled runner agents also receive 409 for open stored REVIEW or AUTOMATION steps or an unreadable ladder, " +
300
+ "even if their computer is paused. Legacy callers follow PROCESS_REVIEW_GATE_ENABLED, currently off; " +
301
+ "an open REVIEW step alone does not block those callers while that legacy gate is off. No API key may complete a REVIEW step. " +
299
302
  "Setting status to cancelled is never gated, because abandoning work must always be possible. " +
300
303
  "Returns the updated issue.",
301
304
  inputSchema: {
@@ -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)) {
@@ -105,7 +128,7 @@ export async function apiRequest(
105
128
  // The config object is mutated so the session uses the live key afterward:
106
129
  // recovering one request and leaving the next twenty to fail would be worse
107
130
  // than not recovering at all, because the failure would look intermittent.
108
- if (response.status === 401 && !config.agentParticipantId) {
131
+ if (response.status === 401 && !config.agentParticipantId && !config.managedConnectionId) {
109
132
  const rotated = rotatedKey(config.apiKey, readFile);
110
133
  if (rotated) {
111
134
  config.apiKey = rotated;
@@ -129,6 +152,13 @@ export async function apiRequest(
129
152
  parsed,
130
153
  );
131
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
+ }
132
162
  const detail =
133
163
  parsed && typeof parsed === "object" && parsed.error ? parsed.error : response.statusText;
134
164
  throw new ApiError(`${method} ${path} failed (${response.status}): ${detail}`, response.status, parsed);
@@ -136,3 +166,63 @@ export async function apiRequest(
136
166
 
137
167
  return parsed;
138
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) {
188
+ const rotated = rotatedKey(config.apiKey, readFile);
189
+ if (rotated) {
190
+ config.apiKey = rotated;
191
+ response = await send(config, method, url, body);
192
+ }
193
+ }
194
+
195
+ const text = await response.text();
196
+ let parsed;
197
+ try {
198
+ parsed = text ? JSON.parse(text) : null;
199
+ } catch {
200
+ parsed = text;
201
+ }
202
+
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
+ }
218
+ const detail =
219
+ parsed && typeof parsed === "object" && parsed.error ? parsed.error : response.statusText;
220
+ throw new ApiError(`${method} ${path} failed (${response.status}): ${detail}`, response.status, parsed);
221
+ }
222
+
223
+ return parsed;
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,7 +28,7 @@ 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 = {}) {
45
32
  const agentParticipantId = env.CIRCULAR_AGENT_PARTICIPANT_ID?.trim();
46
33
  const authorizingUserId = env.CIRCULAR_AUTHORIZING_USER_ID?.trim();
47
34
  const pick = (flagKey, envKey, fileKey, fallback) => {
@@ -51,21 +38,85 @@ export function resolveConfig(flags = {}, env = process.env, file = {}) {
51
38
  return fallback;
52
39
  };
53
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
+
54
94
  return {
55
95
  // A bound session cannot borrow the operator's global credential, including
56
96
  // when its delegated key is missing rather than rejected by the server.
57
- apiKey: pick("api-key", "CIRCULAR_API_KEY", "apiKey", undefined),
97
+ apiKey: explicitRuntimeApiKey ?? legacyFileApiKey,
58
98
  baseUrl: stripTrailingSlash(
59
99
  pick("base-url", "CIRCULAR_BASE_URL", "baseUrl", DEFAULT_BASE_URL)
60
100
  ),
61
- workspaceId: pick("workspace", "CIRCULAR_WORKSPACE_ID", "workspaceId", undefined),
62
- teamId: pick("team", "CIRCULAR_TEAM_ID", "teamId", undefined),
101
+ workspaceId: requestedWorkspaceId,
102
+ teamId: requestedTeamId,
63
103
  ...(agentParticipantId ? { agentParticipantId, ...(authorizingUserId ? { authorizingUserId } : {}) } : {}),
64
104
  };
65
105
  }
66
106
 
67
- export function loadConfig(flags = {}) {
68
- 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);
69
120
  }
70
121
 
71
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.2",
3
+ "version": "0.1.4",
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",