Skip to content

Commit 747a07d

Browse files
committed
cocalc-api: improve api key type discovery
1 parent efdadad commit 747a07d

File tree

8 files changed

+85
-85
lines changed

8 files changed

+85
-85
lines changed

src/packages/conat/hub/api/system.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export interface System {
3232
getCustomize: (fields?: string[]) => Promise<Customize>;
3333
// ping server and get back the current time
3434
ping: () => { now: number };
35-
// test API key and return scope information (account_id or project_id) and server time
36-
test: () => Promise<{ account_id?: string; project_id?: string; server_time: number }>;
35+
// test API key and return scope information (account_id) and server time
36+
test: () => Promise<{ account_id: string; server_time: number }>;
3737
// terminate a service:
3838
// - only admin can do this.
3939
// - useful for development

src/packages/next/pages/api/conat/project.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,6 @@ export default async function handle(req, res) {
5858
args,
5959
timeout,
6060
});
61-
// For project-scoped API keys, include the project_id in the response
62-
// so the client can discover it
63-
if (project_id0 && !resp.project_id) {
64-
resp.project_id = project_id0;
65-
}
6661
res.json(resp);
6762
} catch (err) {
6863
res.json({ error: err.message });

src/packages/server/api/project-bridge.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { projectSubject } from "@cocalc/conat/names";
44
import { conat } from "@cocalc/backend/conat";
55
import { type Client as ConatClient } from "@cocalc/conat/core/client";
66
import { getProject } from "@cocalc/server/projects/control";
7+
78
const DEFAULT_TIMEOUT = 15000;
89

910
let client: ConatClient | null = null;
@@ -58,10 +59,17 @@ async function callProject({
5859
await project.start();
5960
}
6061

61-
// For system.test(), inject project_id into args[0] if not already present
62+
// For discovery-style calls, inject identifiers so the project can report scope
6263
let finalArgs = args;
63-
if (name === "system.test" && (!args || args.length === 0)) {
64-
finalArgs = [{ project_id }];
64+
if (name === "system.test") {
65+
if (!args || args.length === 0 || typeof args[0] !== "object") {
66+
finalArgs = [{}];
67+
}
68+
if (finalArgs[0] == null || typeof finalArgs[0] !== "object") {
69+
finalArgs = [{ project_id }];
70+
} else {
71+
finalArgs = [{ ...finalArgs[0], project_id }];
72+
}
6573
}
6674
const data = { name, args: finalArgs };
6775
// we use waitForInterest because often the project hasn't

src/packages/server/conat/api/system.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,13 @@ export function ping() {
2020

2121
export async function test({
2222
account_id,
23-
project_id,
24-
}: { account_id?: string; project_id?: string } = {}) {
23+
}: { account_id?: string } = {}) {
2524
// Return API key scope information and server time
26-
// The authFirst decorator determines the scope from the API key and injects
27-
// either account_id (for account-scoped keys) or project_id (for project-scoped keys)
28-
// into this parameter object.
29-
const response: { account_id?: string; project_id?: string; server_time: number } = {
25+
// The authFirst decorator determines the scope from the API key and injects account_id.
26+
const response: { account_id: string; server_time: number } = {
27+
account_id: account_id ?? "",
3028
server_time: Date.now(),
3129
};
32-
if (account_id) {
33-
response.account_id = account_id;
34-
}
35-
if (project_id) {
36-
response.project_id = project_id;
37-
}
3830
return response;
3931
}
4032

src/packages/server/projects/control/single-user.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@ class Project extends BaseProject {
156156

157157
// First attempt: graceful shutdown with SIGTERM
158158
// This allows the process to clean up child processes (e.g., Jupyter kernels)
159-
let usedSigterm = false;
159+
const stopStartedAt = Date.now();
160+
const SIGKILL_GRACE_MS = 5000;
161+
let sigkillSent = false;
160162
const killProject = (signal: NodeJS.Signals = "SIGTERM") => {
161163
try {
162164
logger.debug(`stop: sending kill -${pid} with ${signal}`);
@@ -169,16 +171,15 @@ class Project extends BaseProject {
169171

170172
// Try SIGTERM first for graceful shutdown
171173
killProject("SIGTERM");
172-
usedSigterm = true;
173174

174175
await this.wait({
175176
until: async () => {
176177
if (await isProjectRunning(this.HOME)) {
177-
// After 5 seconds, escalate to SIGKILL
178-
if (usedSigterm) {
178+
// After a grace period, escalate to SIGKILL
179+
if (!sigkillSent && Date.now() - stopStartedAt >= SIGKILL_GRACE_MS) {
179180
logger.debug("stop: escalating to SIGKILL");
180181
killProject("SIGKILL");
181-
usedSigterm = false;
182+
sigkillSent = true;
182183
}
183184
return false;
184185
} else {

src/python/cocalc-api/src/cocalc_api/mcp/DEVELOPMENT.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,15 @@ except Exception as e:
174174

175175
- `COCALC_API_KEY` - API key (required)
176176
- `COCALC_HOST` - CoCalc instance URL (optional, defaults to `https://cocalc.com`)
177-
- `COCALC_PROJECT_ID` - Project ID for project-scoped keys (optional, embedded in key)
177+
- `COCALC_PROJECT_ID` - Optional project ID used only with **account-scoped** keys to target a specific project. Ignored for project-scoped keys (project_id comes from the key itself).
178178

179179
### API Key Scope Detection
180180

181-
The server calls `hub.system.test()` to determine scope:
182-
- If returns `account_id` → Account-scoped key
183-
- If returns `project_id` → Project-scoped key
181+
The server detects scope in two steps:
182+
- Call `hub.system.test()` (account-scoped keys only) → returns `account_id`.
183+
- If that fails, call `project.system.test()` → returns `project_id` for project-scoped keys.
184+
185+
If `COCALC_PROJECT_ID` is provided with an account-scoped key, it is used as the default project target; it is ignored for project-scoped keys.
184186

185187
## Available CoCalc APIs
186188

src/python/cocalc-api/src/cocalc_api/mcp/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ A Model Context Protocol (MCP) server that provides LLMs (Claude, etc.) with dir
99
```bash
1010
export COCALC_API_KEY="sk-your-api-key" # Account or project-scoped
1111
export COCALC_HOST="http://localhost:5000" # Optional, defaults to https://cocalc.com
12+
# Optional: only used with account-scoped keys to target a specific project
13+
# export COCALC_PROJECT_ID="your-project-uuid"
1214
```
1315

1416
### 2. Run the Server
@@ -18,6 +20,7 @@ uv run cocalc-mcp-server
1820
```
1921

2022
The server will detect your API key type and automatically register the appropriate tools/resources.
23+
If you supply `COCALC_PROJECT_ID` with an account-scoped key, the MCP server will also prepare a project client for that project. For project-scoped keys, `COCALC_PROJECT_ID` is ignored because the project is embedded in the key.
2124

2225
## Setup with Claude Code
2326

src/python/cocalc-api/src/cocalc_api/mcp/mcp_server.py

Lines changed: 53 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
import os
4141
import sys
4242
import time
43-
from typing import Optional
43+
from typing import Optional, TypedDict, Union, cast
4444

4545
from mcp.server.fastmcp import FastMCP
4646

@@ -93,7 +93,18 @@ def get_config() -> tuple[str, str, Optional[str]]:
9393
return api_key, host, project_id
9494

9595

96-
def check_api_key_scope(api_key: str, host: str) -> dict[str, str]:
96+
class AccountScope(TypedDict):
97+
account_id: str
98+
99+
100+
class ProjectScope(TypedDict):
101+
project_id: str
102+
103+
104+
Scope = Union[AccountScope, ProjectScope]
105+
106+
107+
def check_api_key_scope(api_key: str, host: str) -> Scope:
97108
"""
98109
Check if the API key is account-scoped or project-scoped.
99110
@@ -107,29 +118,26 @@ def check_api_key_scope(api_key: str, host: str) -> dict[str, str]:
107118
Raises:
108119
RuntimeError: If the API key is invalid or scope cannot be determined
109120
"""
121+
# Try account scope first; hub.system.test only works for account-scoped keys
110122
try:
111-
hub = Hub(api_key=api_key, host=host)
112-
113-
# Try the hub.system.test() method (only works for account-scoped keys)
114-
result = hub.system.test()
115-
116-
# Check which scope is returned
117-
if "account_id" in result and result["account_id"]:
118-
return {"account_id": result["account_id"]}
119-
elif "project_id" in result and result["project_id"]:
120-
return {"project_id": result["project_id"]}
121-
else:
122-
raise RuntimeError("API key test returned neither account_id nor project_id")
123-
123+
result = Hub(api_key=api_key, host=host).system.test()
124+
account_id = result.get("account_id")
125+
if account_id:
126+
return {"account_id": account_id}
127+
except Exception:
128+
pass
129+
130+
# Fall back to project scope
131+
try:
132+
result = Project(api_key=api_key, host=host).system.test()
133+
project_id = result.get("project_id")
134+
if project_id:
135+
return {"project_id": project_id}
124136
except Exception as e:
125-
# Check if this looks like a project-scoped key error
126-
error_msg = str(e)
127-
if "must be signed in and MUST provide an api key" in error_msg:
128-
raise RuntimeError("API key appears to be project-scoped. "
129-
"Project-scoped keys require the project_id to be specified at the OS level. "
130-
"Please set the COCALC_PROJECT_ID environment variable and try again.") from e
131137
raise RuntimeError(f"API key validation failed: {e}") from e
132138

139+
raise RuntimeError("API key test returned neither account_id nor project_id")
140+
133141

134142
# Initialize FastMCP server with instructions and documentation
135143
mcp = FastMCP(
@@ -168,7 +176,7 @@ def check_api_key_scope(api_key: str, host: str) -> dict[str, str]:
168176
# Configuration (initialized at startup)
169177
_api_key: Optional[str] = None
170178
_host: Optional[str] = None
171-
_api_key_scope: Optional[dict[str, str]] = None # Either {"account_id": ...} or {"project_id": ...}
179+
_api_key_scope: Optional[Scope] = None # Either {"account_id": ...} or {"project_id": ...}
172180

173181
# Lazy-initialized project clients map: project_id -> Project
174182
_project_clients: dict[str, Project] = {}
@@ -191,41 +199,31 @@ def _initialize_config() -> None:
191199

192200
# Validate API key and determine scope
193201
try:
194-
try:
195-
_api_key_scope = check_api_key_scope(_api_key, _host)
196-
except RuntimeError as check_error:
197-
# If it's a project-scoped key error, use a placeholder project_id
198-
# Project-scoped keys have the project_id embedded in the key itself
199-
if "project-scoped" in str(check_error):
200-
# Use empty string as project_id - the Project client will extract it from the API key
201-
_api_key_scope = {"project_id": ""}
202-
print("✓ Connected with project-scoped API key", file=sys.stderr)
203-
else:
204-
raise
202+
_api_key_scope = check_api_key_scope(_api_key, _host)
205203

206-
if "account_id" in _api_key_scope:
207-
account_id = _api_key_scope["account_id"]
204+
scope = _api_key_scope
205+
if scope is None:
206+
raise RuntimeError("Could not determine API key scope")
207+
208+
if "account_id" in scope:
209+
account_id = cast(AccountScope, scope)["account_id"]
208210
print(f"✓ Connected with account-scoped API key (account: {account_id})", file=sys.stderr)
209-
elif "project_id" in _api_key_scope:
210-
project_id = _api_key_scope["project_id"]
211-
# For project-scoped keys with empty/None project_id, the Project client will extract it from the API key
212-
if project_id:
213-
print(f"✓ Connected with project-scoped API key (project: {project_id})", file=sys.stderr)
214-
# For project-scoped keys, eagerly create the project client
215-
client = Project(api_key=_api_key, project_id=project_id, host=_host)
216-
_project_clients[project_id] = client
217-
else:
218-
# Project-scoped key with empty project_id - will be discovered on first use
219-
print("✓ Connected with project-scoped API key (project ID will be discovered on first use)", file=sys.stderr)
220-
else:
221-
# If we got here with no project_id but it might be project-scoped, check if COCALC_PROJECT_ID was provided
211+
# If a project_id is explicitly provided via env, prepare a client for it
222212
if project_id_config:
223-
_api_key_scope = {"project_id": project_id_config}
224-
print(f"✓ Using project-scoped API key with explicitly provided project_id (project: {project_id_config})", file=sys.stderr)
225213
client = Project(api_key=_api_key, project_id=project_id_config, host=_host)
226214
_project_clients[project_id_config] = client
227-
else:
228-
raise RuntimeError("Could not determine API key scope")
215+
print(
216+
f"✓ Using account-scoped API key with explicitly provided project_id (project: {project_id_config})",
217+
file=sys.stderr,
218+
)
219+
elif "project_id" in scope:
220+
project_id = cast(ProjectScope, scope)["project_id"]
221+
print(f"✓ Connected with project-scoped API key (project: {project_id})", file=sys.stderr)
222+
# For project-scoped keys, eagerly create the project client
223+
client = Project(api_key=_api_key, project_id=project_id, host=_host)
224+
_project_clients[project_id] = client
225+
else:
226+
raise RuntimeError("Could not determine API key scope")
229227

230228
except RuntimeError as e:
231229
print(f"Error: {e}", file=sys.stderr)
@@ -255,8 +253,9 @@ def get_project_client(project_id: Optional[str] = None) -> Project:
255253
# Determine which project_id to use
256254
if project_id is None:
257255
# If no project_id provided, try to use the one from project-scoped key
258-
if _api_key_scope and "project_id" in _api_key_scope:
259-
project_id = _api_key_scope["project_id"]
256+
scope = _api_key_scope
257+
if scope and "project_id" in scope:
258+
project_id = cast(ProjectScope, scope)["project_id"]
260259
else:
261260
# Account-scoped key requires explicit project_id
262261
raise RuntimeError("Account-scoped API key requires an explicit project_id argument. "

0 commit comments

Comments
 (0)