Skip to content

Commit cf71ae9

Browse files
committed
cocalc-api/mcp: first sign of life
1 parent 206f178 commit cf71ae9

File tree

12 files changed

+508
-658
lines changed

12 files changed

+508
-658
lines changed

src/.claude/settings.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,12 @@
5252
"Bash(prettier -w:*)",
5353
"Bash(psql:*)",
5454
"Bash(python3:*)",
55-
<<<<<<< HEAD
5655
"Bash(uv:*)",
5756
"Bash(timeout:*)",
58-
=======
5957
"Bash(uv sync:*)",
60-
>>>>>>> origin/master
6158
"WebFetch",
6259
"WebSearch",
60+
"mcp__cocalc__*",
6361
"mcp__cclsp__find_definition",
6462
"mcp__cclsp__find_references",
6563
"mcp__github__get_issue",

src/packages/server/api/manage.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,17 @@ import isBanned from "@cocalc/server/accounts/is-banned";
2727

2828
const log = getLogger("server:api:manage");
2929

30+
// API key format: new keys start with this prefix (old ones used "sk_")
31+
const API_KEY_PREFIX = "sk-";
32+
3033
// Global per user limit to avoid abuse/bugs. Nobody should ever hit this.
3134
// Since we use a separate key per compute server, and definitely want some users
3235
// to create 5K compute servers at once, don't make this too small.
3336
const MAX_API_KEYS = 100000;
3437

38+
// PostgreSQL SERIAL type max value (32-bit signed integer)
39+
const MAX_SERIAL = 2147483647;
40+
3541
// Converts any 32-bit nonnegative integer as a 6-character base-62 string.
3642
function encode62(n: number): string {
3743
if (!Number.isInteger(n)) {
@@ -184,8 +190,8 @@ async function createApiKey({
184190
// We encode the id in the secret so when the user presents the secret we can find the record.
185191
// Note that passwordHash is NOT a "function" -- due to salt every time you call it, the output is different!
186192
// Thus we have to do this little trick.
187-
// New ones start with sk- and old with sk_.
188-
const secret = `sk-${generate(16)}${encode62(id)}`;
193+
// New ones start with API_KEY_PREFIX and old with sk_.
194+
const secret = `${API_KEY_PREFIX}${generate(16)}${encode62(id)}`;
189195
const trunc = secret.slice(0, 3) + "..." + secret.slice(-6);
190196
const hash = passwordHash(secret);
191197
await pool.query("UPDATE api_keys SET trunc=$1,hash=$2 WHERE id=$3", [
@@ -283,7 +289,13 @@ export async function getAccountWithApiKey(
283289
log.debug("getAccountWithApiKey");
284290
const pool = getPool("medium");
285291

286-
// Check for legacy account api key:
292+
// Validate secret format
293+
if (!secret || typeof secret !== "string") {
294+
log.debug("getAccountWithApiKey: invalid secret - not a string");
295+
return;
296+
}
297+
298+
// Check for legacy account api key (format: sk_*)
287299
if (secret.startsWith("sk_")) {
288300
const { rows } = await pool.query(
289301
"SELECT account_id FROM accounts WHERE api_key = $1::TEXT",
@@ -301,8 +313,37 @@ export async function getAccountWithApiKey(
301313
}
302314
}
303315

304-
// Check new api_keys table
305-
const id = decode62(secret.slice(-6));
316+
// Check new api_keys table (format: {API_KEY_PREFIX}{random_16_chars}{base62_encoded_id})
317+
// Expected length: 3 + 16 + 6 = 25 characters minimum
318+
if (!secret.startsWith(API_KEY_PREFIX) || secret.length < 9) {
319+
log.debug("getAccountWithApiKey: invalid api key format", {
320+
startsWithPrefix: secret.startsWith(API_KEY_PREFIX),
321+
length: secret.length,
322+
});
323+
return;
324+
}
325+
326+
// Decode the last 6 characters as base62 to get the ID
327+
let id: number;
328+
try {
329+
id = decode62(secret.slice(-6));
330+
} catch (err) {
331+
log.debug("getAccountWithApiKey: failed to decode api key id", {
332+
suffix: secret.slice(-6),
333+
error: err instanceof Error ? err.message : String(err),
334+
});
335+
return;
336+
}
337+
338+
// Validate that ID is within valid PostgreSQL SERIAL (32-bit) range
339+
if (!Number.isInteger(id) || id < 0 || id > MAX_SERIAL) {
340+
log.debug("getAccountWithApiKey: decoded id out of valid range", {
341+
id,
342+
max: MAX_SERIAL,
343+
});
344+
return;
345+
}
346+
306347
const { rows } = await pool.query(
307348
"SELECT account_id,project_id,hash,expire FROM api_keys WHERE id=$1",
308349
[id],

src/python/cocalc-api/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ help:
1111
@echo " coverage - Run tests with coverage reporting (HTML + terminal)"
1212
@echo " coverage-report - Show coverage report in terminal"
1313
@echo " coverage-html - Generate HTML coverage report only"
14-
@echo " mcp - Start the MCP server (requires COCALC_API_KEY and COCALC_PROJECT_ID)"
14+
@echo " mcp - Start the MCP server (requires COCALC_API_KEY, COCALC_PROJECT_ID, and COCALC_HOST)"
1515
@echo " serve-docs - Serve documentation locally"
1616
@echo " build-docs - Build documentation"
1717
@echo " publish - Build and publish package"

src/python/cocalc-api/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ The `Project` class provides project-specific operations:
4949

5050
- **System**: Execute shell commands and Jupyter code within a specific project
5151

52+
## MCP Server
53+
54+
The CoCalc API includes a **Model Context Protocol (MCP) server** that allows LLMs (like Claude) to interact with CoCalc projects through a standardized protocol.
55+
56+
For detailed setup instructions and usage guide, see [src/cocalc_api/mcp/README.md](src/cocalc_api/mcp/README.md).
57+
5258
## Authentication
5359

5460
The client supports two types of API keys:
@@ -165,4 +171,4 @@ MIT License. See the [LICENSE](LICENSE) file for details.
165171
- [CoCalc Website](https://cocalc.com)
166172
- [Documentation](https://cocalc.com/api/python)
167173
- [Source Code](https://github.com/sagemathinc/cocalc/tree/master/src/python/cocalc-api)
168-
- [Issue Tracker](https://github.com/sagemathinc/cocalc/issues)
174+
- [Issue Tracker](https://github.com/sagemathinc/cocalc/issues)

0 commit comments

Comments
 (0)