@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 +58 -13
- package/bin/circular-mcp.mjs +4 -4
- package/lib/server.mjs +7 -5
- package/lib/tools.mjs +13 -10
- package/lib/vendor/client.mjs +119 -29
- package/lib/vendor/config.mjs +71 -20
- package/lib/vendor/connections.mjs +149 -0
- package/package.json +1 -1
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.
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
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
|
package/bin/circular-mcp.mjs
CHANGED
|
@@ -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:
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
33
|
-
"
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
113
|
-
"
|
|
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
|
|
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
|
|
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
|
-
"
|
|
291
|
-
"status to in_progress
|
|
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
|
-
"
|
|
298
|
-
"
|
|
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: {
|
package/lib/vendor/client.mjs
CHANGED
|
@@ -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("
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|
package/lib/vendor/config.mjs
CHANGED
|
@@ -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:
|
|
97
|
+
apiKey: explicitRuntimeApiKey ?? legacyFileApiKey,
|
|
58
98
|
baseUrl: stripTrailingSlash(
|
|
59
99
|
pick("base-url", "CIRCULAR_BASE_URL", "baseUrl", DEFAULT_BASE_URL)
|
|
60
100
|
),
|
|
61
|
-
workspaceId:
|
|
62
|
-
teamId:
|
|
101
|
+
workspaceId: requestedWorkspaceId,
|
|
102
|
+
teamId: requestedTeamId,
|
|
63
103
|
...(agentParticipantId ? { agentParticipantId, ...(authorizingUserId ? { authorizingUserId } : {}) } : {}),
|
|
64
104
|
};
|
|
65
105
|
}
|
|
66
106
|
|
|
67
|
-
export function loadConfig(flags = {}) {
|
|
68
|
-
|
|
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