Build
* cross build switches to pkgsCross.ucrt64 (shell-win.nix); curl (Schannel),
winpthreads and ncurses 6.6 are linked statically, so the release is
cst-agent.exe + busybox.exe (busybox-w32 Unicode) with no extra DLLs
* windres embeds a manifest with activeCodePage=UTF-8 and a version resource
* ncurses 6.6 Windows console driver patches (mouse button state, A_DIM,
wide-char declarations): scripts/patches/
* scripts/bootstrap_msys2.sh builds the same release natively in MSYS2
UCRT64; PR #1's MINGW64 notes now point there
* vendor/tre-regex (musl's TRE) provides <regex.h> on MinGW
UTF-8
* paths cross the system call boundary as UTF-16 (*W APIs); console code
pages set to 65001 and restored on exit; files are read and written in
binary mode
* child output that is not valid UTF-8 is decoded from the console, OEM or
ANSI system code page
* read_file hides CR; edit_file, apply_patch and write_file keep CRLF line
endings and a UTF-8 BOM
OS layer and processes
* os_dir_* and cst_fnmatch replace the POSIX-only directory walks in
subagent, plugins, skills, undo and fs_tools; realpath / path_under and
symlink rejection work on Windows
* every child goes through CreateProcessW with its own environment block
inside a Job Object, so timeouts and Esc end the whole process tree
Sandbox (auto mode, "auto · lowil")
* run_shell runs with a restricted Low integrity token; the workspace gets
an inheritable Low label once, the sandbox TEMP is labeled Low, and the
credentials file gets a no-read-up label
* drive roots, the profile and system folders are never labeled
* network commands still ask, since Low IL does not block the network;
Access denied and MSYS2's 0xc0000142 offer a rerun outside the sandbox
* /sandbox lists labeled folders, /sandbox reset-labels restores them
TUI
* raster console fonts are switched to Consolas / NSimSun while running
* the home logo falls back to text when the console font lacks U+2580
* mouse drag copy goes to the Windows clipboard
Distribution
* scripts/install_windows.ps1 installs a Gitea release into
%LOCALAPPDATA%\Programs\cst-agent and adds it to the user PATH;
cst-agent-update.cmd reruns it; scripts/release_windows.sh uploads the
zip with the token taken from the environment
* scripts/run_windows.bat is gone; docs/README-windows.md is rewritten
Tests
* new test_lowil, test_win_utf8 and test_os_dir; tests made portable
(cst_test_util.h)
* scripts/win_test.sh and scripts/winvm/ run the suite on English and
Chinese Windows Server VMs as Administrator, a standard user and a
Chinese user name: 29/29 on all six
938 lines
28 KiB
C
938 lines
28 KiB
C
#ifndef _DEFAULT_SOURCE
|
|
#define _DEFAULT_SOURCE
|
|
#endif
|
|
#ifndef _XOPEN_SOURCE
|
|
#define _XOPEN_SOURCE 700
|
|
#endif
|
|
|
|
#include "cst_mcp.h"
|
|
|
|
#include <pthread.h>
|
|
#include <stdatomic.h>
|
|
#include <stdarg.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
#ifdef _WIN32
|
|
#include <io.h>
|
|
#define close _close
|
|
#else
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
#include "cJSON.h"
|
|
#include "cst_tools.h"
|
|
#include "cst_util.h"
|
|
#include "cst_version.h"
|
|
#include "os_compat.h"
|
|
|
|
#define MCP_PEND 16
|
|
#define MCP_INIT_TIMEOUT_MS 30000
|
|
#define MCP_REQ_TIMEOUT_MS 60000
|
|
#define MCP_LINE_MAX (16 * 1024 * 1024)
|
|
#define MCP_OUT_MAX (256 * 1024)
|
|
#define MCP_DESC_MAX 1024
|
|
#define MCP_PROTOCOL "2025-06-18"
|
|
|
|
typedef struct mcp_srv mcp_srv_t;
|
|
|
|
typedef struct mtool {
|
|
mcp_srv_t *srv;
|
|
char orig[256]; /* the server's own tool name */
|
|
struct mtool *next;
|
|
} mtool_t;
|
|
|
|
struct mcp_srv {
|
|
char name[64];
|
|
char source[48];
|
|
char owner[96];
|
|
char cwd[OS_PATH_MAX];
|
|
char command[256];
|
|
char **argv; /* NULL-terminated */
|
|
char **env; /* "K=V", NULL-terminated */
|
|
int auto_all;
|
|
char **auto_tools; /* NULL-terminated */
|
|
int disabled;
|
|
char err_log[OS_PATH_MAX];
|
|
|
|
pthread_mutex_t mu; /* state, pending */
|
|
pthread_mutex_t wmu; /* writes to the server's stdin */
|
|
pthread_cond_t cv;
|
|
int state;
|
|
char error[200];
|
|
int ntools;
|
|
atomic_int stopping;
|
|
os_proc_t proc;
|
|
int spawned;
|
|
pthread_t reader, starter;
|
|
int reader_on, starter_on;
|
|
long next_id;
|
|
struct {
|
|
long id;
|
|
int used, done;
|
|
char *json;
|
|
} pend[MCP_PEND];
|
|
mtool_t *tools;
|
|
};
|
|
|
|
static mcp_srv_t *g_srv[CST_MCP_MAX];
|
|
static int g_nsrv;
|
|
static char g_ws[OS_PATH_MAX];
|
|
|
|
/* ---------- config ---------- */
|
|
|
|
static void strv_free(char **v) {
|
|
for (int i = 0; v && v[i]; i++)
|
|
free(v[i]);
|
|
free(v);
|
|
}
|
|
|
|
/* ${NAME} -> getenv(NAME) (empty when unset). */
|
|
static char *expand(const char *s) {
|
|
cst_buf_t b = {0};
|
|
while (*s) {
|
|
const char *e;
|
|
if (s[0] == '$' && s[1] == '{' && (e = strchr(s + 2, '}')) != NULL &&
|
|
e - s - 2 < 128) {
|
|
char var[130];
|
|
memcpy(var, s + 2, (size_t)(e - s - 2));
|
|
var[e - s - 2] = 0;
|
|
const char *v = getenv(var);
|
|
if (v)
|
|
cst_buf_puts(&b, v);
|
|
s = e + 1;
|
|
} else {
|
|
cst_buf_append(&b, s, 1);
|
|
s++;
|
|
}
|
|
}
|
|
if (!b.buf)
|
|
cst_buf_puts(&b, "");
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
static mcp_srv_t *find_srv(const char *name) {
|
|
for (int i = 0; i < g_nsrv; i++)
|
|
if (strcmp(g_srv[i]->name, name) == 0)
|
|
return g_srv[i];
|
|
return NULL;
|
|
}
|
|
|
|
static void srv_free(mcp_srv_t *s) {
|
|
if (!s)
|
|
return;
|
|
strv_free(s->argv);
|
|
strv_free(s->env);
|
|
strv_free(s->auto_tools);
|
|
while (s->tools) {
|
|
mtool_t *n = s->tools->next;
|
|
free(s->tools);
|
|
s->tools = n;
|
|
}
|
|
for (int i = 0; i < MCP_PEND; i++)
|
|
free(s->pend[i].json);
|
|
pthread_mutex_destroy(&s->mu);
|
|
pthread_mutex_destroy(&s->wmu);
|
|
pthread_cond_destroy(&s->cv);
|
|
free(s);
|
|
}
|
|
|
|
int cst_mcp_add_config_json(const char *name, const char *server_json,
|
|
const char *cwd, const char *source) {
|
|
if (!name || !name[0] || !server_json || g_nsrv >= CST_MCP_MAX)
|
|
return -1;
|
|
char sname[64];
|
|
cst_tool_sanitize_name(name, sname, sizeof(sname));
|
|
if (find_srv(sname))
|
|
return -1;
|
|
cJSON *j = cJSON_Parse(server_json);
|
|
if (!cJSON_IsObject(j)) {
|
|
cJSON_Delete(j);
|
|
return -1;
|
|
}
|
|
mcp_srv_t *s = calloc(1, sizeof(*s));
|
|
if (!s) {
|
|
cJSON_Delete(j);
|
|
return -1;
|
|
}
|
|
pthread_mutex_init(&s->mu, NULL);
|
|
pthread_mutex_init(&s->wmu, NULL);
|
|
pthread_cond_init(&s->cv, NULL);
|
|
snprintf(s->name, sizeof(s->name), "%s", sname);
|
|
snprintf(s->source, sizeof(s->source), "%s", source ? source : "user");
|
|
snprintf(s->owner, sizeof(s->owner), "mcp:%s", sname);
|
|
|
|
const cJSON *dis = cJSON_GetObjectItemCaseSensitive(j, "disabled");
|
|
const cJSON *cmd = cJSON_GetObjectItemCaseSensitive(j, "command");
|
|
const cJSON *url = cJSON_GetObjectItemCaseSensitive(j, "url");
|
|
const cJSON *type = cJSON_GetObjectItemCaseSensitive(j, "type");
|
|
if (cJSON_IsTrue(dis)) {
|
|
s->disabled = 1;
|
|
snprintf(s->error, sizeof(s->error), "disabled in the config");
|
|
}
|
|
if (url || (cJSON_IsString(type) && strcmp(type->valuestring, "stdio"))) {
|
|
s->state = CST_MCP_FAILED;
|
|
snprintf(s->error, sizeof(s->error),
|
|
"only stdio servers (\"command\") are supported");
|
|
} else if (!cJSON_IsString(cmd) || !cmd->valuestring[0]) {
|
|
s->state = CST_MCP_FAILED;
|
|
snprintf(s->error, sizeof(s->error), "missing \"command\"");
|
|
} else {
|
|
const cJSON *args = cJSON_GetObjectItemCaseSensitive(j, "args");
|
|
int na = cJSON_IsArray(args) ? cJSON_GetArraySize(args) : 0;
|
|
s->argv = calloc((size_t)na + 2, sizeof(char *));
|
|
if (s->argv) {
|
|
s->argv[0] = expand(cmd->valuestring);
|
|
int k = 1;
|
|
const cJSON *a = NULL;
|
|
cJSON_ArrayForEach(a, args) {
|
|
if (cJSON_IsString(a))
|
|
s->argv[k++] = expand(a->valuestring);
|
|
else if (cJSON_IsNumber(a)) {
|
|
char nb[32];
|
|
snprintf(nb, sizeof(nb), "%g", a->valuedouble);
|
|
s->argv[k++] = strdup(nb);
|
|
}
|
|
}
|
|
cst_buf_t c = {0};
|
|
for (int i = 0; s->argv[i]; i++)
|
|
cst_buf_printf(&c, "%s%s", i ? " " : "", s->argv[i]);
|
|
snprintf(s->command, sizeof(s->command), "%s", c.buf ? c.buf : "");
|
|
cst_buf_free(&c);
|
|
}
|
|
const cJSON *env = cJSON_GetObjectItemCaseSensitive(j, "env");
|
|
int ne = cJSON_IsObject(env) ? cJSON_GetArraySize(env) : 0;
|
|
s->env = calloc((size_t)ne + 1, sizeof(char *));
|
|
if (s->env) {
|
|
int k = 0;
|
|
const cJSON *e = NULL;
|
|
cJSON_ArrayForEach(e, env) {
|
|
if (!cJSON_IsString(e) || !e->string)
|
|
continue;
|
|
char *v = expand(e->valuestring);
|
|
cst_buf_t kv = {0};
|
|
cst_buf_printf(&kv, "%s=%s", e->string, v ? v : "");
|
|
free(v);
|
|
s->env[k++] = cst_buf_steal(&kv);
|
|
}
|
|
}
|
|
}
|
|
const cJSON *wd = cJSON_GetObjectItemCaseSensitive(j, "cwd");
|
|
if (cJSON_IsString(wd) && wd->valuestring[0]) {
|
|
char *w = expand(wd->valuestring);
|
|
if (w && (w[0] == '/' || !cwd || !cwd[0]
|
|
#ifdef _WIN32
|
|
|| (w[0] && w[1] == ':')
|
|
#endif
|
|
))
|
|
snprintf(s->cwd, sizeof(s->cwd), "%s", w);
|
|
else if (w)
|
|
snprintf(s->cwd, sizeof(s->cwd), "%.2000s/%.2000s", cwd, w);
|
|
free(w);
|
|
} else if (cwd && cwd[0]) {
|
|
snprintf(s->cwd, sizeof(s->cwd), "%s", cwd);
|
|
}
|
|
const cJSON *aa = cJSON_GetObjectItemCaseSensitive(j, "autoApprove");
|
|
if (cJSON_IsTrue(aa)) {
|
|
s->auto_all = 1;
|
|
} else if (cJSON_IsArray(aa)) {
|
|
int n = cJSON_GetArraySize(aa), k = 0;
|
|
s->auto_tools = calloc((size_t)n + 1, sizeof(char *));
|
|
const cJSON *t = NULL;
|
|
cJSON_ArrayForEach(t, aa) {
|
|
if (s->auto_tools && cJSON_IsString(t))
|
|
s->auto_tools[k++] = strdup(t->valuestring);
|
|
}
|
|
}
|
|
cJSON_Delete(j);
|
|
g_srv[g_nsrv++] = s;
|
|
return 0;
|
|
}
|
|
|
|
static char *slurp(const char *path, size_t max) {
|
|
FILE *fp = fopen(path, "rb");
|
|
if (!fp)
|
|
return NULL;
|
|
cst_buf_t b = {0};
|
|
char tmp[8192];
|
|
size_t n;
|
|
while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0) {
|
|
if (b.len + n > max) {
|
|
cst_buf_free(&b);
|
|
fclose(fp);
|
|
return NULL;
|
|
}
|
|
cst_buf_append(&b, tmp, n);
|
|
}
|
|
fclose(fp);
|
|
if (!b.buf)
|
|
cst_buf_puts(&b, "");
|
|
cst_strip_bom(b.buf, &b.len);
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
static int load_file(const char *path, const char *cwd, const char *source,
|
|
int count_only) {
|
|
char *text = slurp(path, 1024 * 1024);
|
|
if (!text)
|
|
return 0;
|
|
cJSON *root = cJSON_Parse(text);
|
|
free(text);
|
|
const cJSON *servers = cJSON_GetObjectItemCaseSensitive(root, "mcpServers");
|
|
if (!servers)
|
|
servers = cJSON_GetObjectItemCaseSensitive(root, "servers");
|
|
int n = 0;
|
|
const cJSON *sv = NULL;
|
|
cJSON_ArrayForEach(sv, servers) {
|
|
if (!sv->string)
|
|
continue;
|
|
if (count_only) {
|
|
n++;
|
|
continue;
|
|
}
|
|
char *sj = cJSON_PrintUnformatted(sv);
|
|
if (sj && cst_mcp_add_config_json(sv->string, sj, cwd, source) == 0)
|
|
n++;
|
|
free(sj);
|
|
}
|
|
cJSON_Delete(root);
|
|
return n;
|
|
}
|
|
|
|
int cst_mcp_load_configs(const char *ws, int ws_trusted, int *untrusted) {
|
|
if (untrusted)
|
|
*untrusted = 0;
|
|
int n = 0;
|
|
char p[OS_PATH_MAX];
|
|
if (ws && ws[0]) {
|
|
snprintf(g_ws, sizeof(g_ws), "%s", ws);
|
|
snprintf(p, sizeof(p), "%.4000s/.agent/mcp.json", ws);
|
|
if (ws_trusted)
|
|
n += load_file(p, ws, "workspace", 0);
|
|
else if (untrusted)
|
|
*untrusted = load_file(p, ws, "workspace", 1);
|
|
}
|
|
char cfg[OS_PATH_MAX];
|
|
if (os_config_dir(cfg, sizeof(cfg)) == 0) {
|
|
snprintf(p, sizeof(p), "%.4000s/mcp.json", cfg);
|
|
n += load_file(p, ws, "user", 0);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/* ---------- JSON-RPC ---------- */
|
|
|
|
static void set_failed(mcp_srv_t *s, int state, const char *fmt, ...)
|
|
__attribute__((format(printf, 3, 4)));
|
|
|
|
static void set_failed(mcp_srv_t *s, int state, const char *fmt, ...) {
|
|
pthread_mutex_lock(&s->mu);
|
|
s->state = state;
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
vsnprintf(s->error, sizeof(s->error), fmt, ap);
|
|
va_end(ap);
|
|
pthread_cond_broadcast(&s->cv);
|
|
pthread_mutex_unlock(&s->mu);
|
|
}
|
|
|
|
/* Last non-empty line of the server's stderr log, for error messages. */
|
|
static void stderr_tail(const mcp_srv_t *s, char *out, size_t sz) {
|
|
out[0] = 0;
|
|
if (!s->err_log[0])
|
|
return;
|
|
char *t = slurp(s->err_log, 4 * 1024 * 1024);
|
|
if (!t)
|
|
return;
|
|
size_t n = strlen(t);
|
|
while (n && (t[n - 1] == '\n' || t[n - 1] == '\r' || t[n - 1] == ' '))
|
|
t[--n] = 0;
|
|
char *line = strrchr(t, '\n');
|
|
line = line ? line + 1 : t;
|
|
if (*line)
|
|
snprintf(out, sz, "%s", line);
|
|
free(t);
|
|
}
|
|
|
|
static int send_json(mcp_srv_t *s, cJSON *msg) {
|
|
char *txt = cJSON_PrintUnformatted(msg);
|
|
if (!txt)
|
|
return -1;
|
|
pthread_mutex_lock(&s->wmu);
|
|
int rc = os_proc_write(&s->proc, txt, strlen(txt));
|
|
if (rc == 0)
|
|
rc = os_proc_write(&s->proc, "\n", 1);
|
|
pthread_mutex_unlock(&s->wmu);
|
|
free(txt);
|
|
return rc;
|
|
}
|
|
|
|
static void notify(mcp_srv_t *s, const char *method, cJSON *params) {
|
|
cJSON *m = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(m, "jsonrpc", "2.0");
|
|
cJSON_AddStringToObject(m, "method", method);
|
|
if (params)
|
|
cJSON_AddItemToObject(m, "params", params);
|
|
send_json(s, m);
|
|
cJSON_Delete(m);
|
|
}
|
|
|
|
static int srv_alive(const mcp_srv_t *s) {
|
|
return s->state == CST_MCP_STARTING || s->state == CST_MCP_READY;
|
|
}
|
|
|
|
/* Send a request and wait for its response object. params is consumed.
|
|
* NULL with err set on timeout, cancel, a dead server or a write error. */
|
|
static cJSON *request(mcp_srv_t *s, const char *method, cJSON *params,
|
|
int timeout_ms, char *err, size_t esz) {
|
|
pthread_mutex_lock(&s->mu);
|
|
int slot = -1;
|
|
for (int i = 0; i < MCP_PEND && slot < 0; i++)
|
|
if (!s->pend[i].used)
|
|
slot = i;
|
|
if (!srv_alive(s) || slot < 0) {
|
|
snprintf(err, esz, "%s", slot < 0 ? "too many requests in flight"
|
|
: "server is not running");
|
|
pthread_mutex_unlock(&s->mu);
|
|
cJSON_Delete(params);
|
|
return NULL;
|
|
}
|
|
long id = ++s->next_id;
|
|
s->pend[slot].used = 1;
|
|
s->pend[slot].done = 0;
|
|
s->pend[slot].id = id;
|
|
s->pend[slot].json = NULL;
|
|
pthread_mutex_unlock(&s->mu);
|
|
|
|
cJSON *m = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(m, "jsonrpc", "2.0");
|
|
cJSON_AddNumberToObject(m, "id", (double)id);
|
|
cJSON_AddStringToObject(m, "method", method);
|
|
if (params)
|
|
cJSON_AddItemToObject(m, "params", params);
|
|
int wrc = send_json(s, m);
|
|
cJSON_Delete(m);
|
|
|
|
double deadline = os_now_ms() + timeout_ms;
|
|
char *json = NULL;
|
|
int why = 0; /* 1 timeout, 2 cancel, 3 dead, 4 write */
|
|
pthread_mutex_lock(&s->mu);
|
|
if (wrc != 0)
|
|
why = 4;
|
|
while (!why && !s->pend[slot].done) {
|
|
if (!srv_alive(s)) {
|
|
why = 3;
|
|
break;
|
|
}
|
|
if (os_cancel_requested()) {
|
|
why = 2;
|
|
break;
|
|
}
|
|
if (os_now_ms() > deadline) {
|
|
why = 1;
|
|
break;
|
|
}
|
|
struct timespec ts;
|
|
clock_gettime(CLOCK_REALTIME, &ts);
|
|
ts.tv_nsec += 100 * 1000000L;
|
|
if (ts.tv_nsec >= 1000000000L) {
|
|
ts.tv_sec++;
|
|
ts.tv_nsec -= 1000000000L;
|
|
}
|
|
pthread_cond_timedwait(&s->cv, &s->mu, &ts);
|
|
}
|
|
if (s->pend[slot].done) {
|
|
json = s->pend[slot].json;
|
|
why = 0;
|
|
}
|
|
s->pend[slot].json = NULL;
|
|
s->pend[slot].used = 0;
|
|
char serr[200];
|
|
snprintf(serr, sizeof(serr), "%s", s->error);
|
|
pthread_mutex_unlock(&s->mu);
|
|
|
|
if (why == 1 || why == 2) {
|
|
cJSON *p = cJSON_CreateObject();
|
|
cJSON_AddNumberToObject(p, "requestId", (double)id);
|
|
cJSON_AddStringToObject(p, "reason", why == 1 ? "timeout" : "cancelled");
|
|
notify(s, "notifications/cancelled", p);
|
|
}
|
|
if (why) {
|
|
if (why == 1)
|
|
snprintf(err, esz, "no response to %s within %d s", method,
|
|
timeout_ms / 1000);
|
|
else if (why == 2)
|
|
snprintf(err, esz, "cancelled");
|
|
else if (why == 3)
|
|
snprintf(err, esz, "server stopped%s%s", serr[0] ? ": " : "", serr);
|
|
else
|
|
snprintf(err, esz, "cannot write to the server");
|
|
free(json);
|
|
return NULL;
|
|
}
|
|
cJSON *resp = cJSON_Parse(json);
|
|
free(json);
|
|
if (!resp)
|
|
snprintf(err, esz, "invalid JSON response");
|
|
return resp;
|
|
}
|
|
|
|
/* JSON-RPC error object -> "message (code N)". */
|
|
static int rpc_error(const cJSON *resp, char *err, size_t esz) {
|
|
const cJSON *e = cJSON_GetObjectItemCaseSensitive(resp, "error");
|
|
if (!e)
|
|
return 0;
|
|
const cJSON *msg = cJSON_GetObjectItemCaseSensitive(e, "message");
|
|
const cJSON *code = cJSON_GetObjectItemCaseSensitive(e, "code");
|
|
snprintf(err, esz, "%s (code %d)",
|
|
cJSON_IsString(msg) ? msg->valuestring : "error",
|
|
cJSON_IsNumber(code) ? code->valueint : 0);
|
|
return 1;
|
|
}
|
|
|
|
static void answer_server_request(mcp_srv_t *s, const cJSON *msg) {
|
|
const cJSON *id = cJSON_GetObjectItemCaseSensitive(msg, "id");
|
|
const cJSON *method = cJSON_GetObjectItemCaseSensitive(msg, "method");
|
|
cJSON *r = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(r, "jsonrpc", "2.0");
|
|
cJSON_AddItemToObject(r, "id", cJSON_Duplicate(id, 1));
|
|
const char *m = cJSON_IsString(method) ? method->valuestring : "";
|
|
if (strcmp(m, "ping") == 0) {
|
|
cJSON_AddItemToObject(r, "result", cJSON_CreateObject());
|
|
} else if (strcmp(m, "roots/list") == 0 && g_ws[0]) {
|
|
cJSON *res = cJSON_AddObjectToObject(r, "result");
|
|
cJSON *roots = cJSON_AddArrayToObject(res, "roots");
|
|
cJSON *root = cJSON_CreateObject();
|
|
char real[OS_PATH_MAX], uri[OS_PATH_MAX + 16];
|
|
if (os_realpath(g_ws, real, sizeof(real)) != 0)
|
|
snprintf(real, sizeof(real), "%s", g_ws);
|
|
snprintf(uri, sizeof(uri), "file://%s", real);
|
|
cJSON_AddStringToObject(root, "uri", uri);
|
|
cJSON_AddStringToObject(root, "name", "workspace");
|
|
cJSON_AddItemToArray(roots, root);
|
|
} else {
|
|
cJSON *e = cJSON_AddObjectToObject(r, "error");
|
|
cJSON_AddNumberToObject(e, "code", -32601);
|
|
cJSON_AddStringToObject(e, "message", "method not supported by client");
|
|
}
|
|
send_json(s, r);
|
|
cJSON_Delete(r);
|
|
}
|
|
|
|
static void handle_line(mcp_srv_t *s, const char *line) {
|
|
cJSON *msg = cJSON_Parse(line);
|
|
if (!cJSON_IsObject(msg)) {
|
|
cJSON_Delete(msg); /* servers sometimes log to stdout; ignore */
|
|
return;
|
|
}
|
|
const cJSON *id = cJSON_GetObjectItemCaseSensitive(msg, "id");
|
|
const cJSON *method = cJSON_GetObjectItemCaseSensitive(msg, "method");
|
|
if (method) {
|
|
if (id)
|
|
answer_server_request(s, msg);
|
|
} else if (cJSON_IsNumber(id)) {
|
|
long want = (long)id->valuedouble;
|
|
pthread_mutex_lock(&s->mu);
|
|
for (int i = 0; i < MCP_PEND; i++) {
|
|
if (s->pend[i].used && !s->pend[i].done && s->pend[i].id == want) {
|
|
s->pend[i].json = strdup(line);
|
|
s->pend[i].done = 1;
|
|
pthread_cond_broadcast(&s->cv);
|
|
break;
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&s->mu);
|
|
}
|
|
cJSON_Delete(msg);
|
|
}
|
|
|
|
static void *reader_main(void *arg) {
|
|
mcp_srv_t *s = arg;
|
|
cst_buf_t line = {0};
|
|
char tmp[16384];
|
|
int overflow = 0;
|
|
for (;;) {
|
|
long n = os_proc_read(&s->proc, tmp, sizeof(tmp), 200);
|
|
if (n == -2) {
|
|
if (atomic_load(&s->stopping))
|
|
break;
|
|
continue;
|
|
}
|
|
if (n <= 0)
|
|
break;
|
|
for (long i = 0; i < n; i++) {
|
|
if (tmp[i] != '\n') {
|
|
if (line.len < MCP_LINE_MAX)
|
|
cst_buf_append(&line, tmp + i, 1);
|
|
else
|
|
overflow = 1;
|
|
continue;
|
|
}
|
|
if (line.len && line.buf[line.len - 1] == '\r')
|
|
line.buf[--line.len] = 0;
|
|
if (line.len && !overflow)
|
|
handle_line(s, line.buf);
|
|
line.len = 0;
|
|
if (line.buf)
|
|
line.buf[0] = 0;
|
|
overflow = 0;
|
|
}
|
|
}
|
|
cst_buf_free(&line);
|
|
if (!atomic_load(&s->stopping)) {
|
|
char tail[160];
|
|
stderr_tail(s, tail, sizeof(tail));
|
|
set_failed(s, CST_MCP_EXITED, "server exited%s%s", tail[0] ? ": " : "",
|
|
tail);
|
|
} else {
|
|
set_failed(s, CST_MCP_EXITED, "stopped");
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* ---------- tools ---------- */
|
|
|
|
static int auto_approved(const mcp_srv_t *s, const char *tool) {
|
|
if (s->auto_all)
|
|
return 1;
|
|
for (int i = 0; s->auto_tools && s->auto_tools[i]; i++)
|
|
if (strcmp(s->auto_tools[i], tool) == 0)
|
|
return 1;
|
|
return 0;
|
|
}
|
|
|
|
static void put_content(cst_buf_t *b, const cJSON *item) {
|
|
const cJSON *type = cJSON_GetObjectItemCaseSensitive(item, "type");
|
|
const char *t = cJSON_IsString(type) ? type->valuestring : "";
|
|
if (b->len && b->buf[b->len - 1] != '\n')
|
|
cst_buf_puts(b, "\n");
|
|
if (strcmp(t, "text") == 0) {
|
|
const cJSON *x = cJSON_GetObjectItemCaseSensitive(item, "text");
|
|
if (cJSON_IsString(x))
|
|
cst_buf_puts(b, x->valuestring);
|
|
} else if (strcmp(t, "image") == 0 || strcmp(t, "audio") == 0) {
|
|
const cJSON *mt = cJSON_GetObjectItemCaseSensitive(item, "mimeType");
|
|
const cJSON *d = cJSON_GetObjectItemCaseSensitive(item, "data");
|
|
cst_buf_printf(b, "[%s %s, %zu bytes base64, not shown]", t,
|
|
cJSON_IsString(mt) ? mt->valuestring : "",
|
|
cJSON_IsString(d) ? strlen(d->valuestring) : (size_t)0);
|
|
} else if (strcmp(t, "resource") == 0) {
|
|
const cJSON *r = cJSON_GetObjectItemCaseSensitive(item, "resource");
|
|
const cJSON *x = cJSON_GetObjectItemCaseSensitive(r, "text");
|
|
const cJSON *uri = cJSON_GetObjectItemCaseSensitive(r, "uri");
|
|
if (cJSON_IsString(x))
|
|
cst_buf_puts(b, x->valuestring);
|
|
else
|
|
cst_buf_printf(b, "[resource %s]",
|
|
cJSON_IsString(uri) ? uri->valuestring : "");
|
|
} else if (strcmp(t, "resource_link") == 0) {
|
|
const cJSON *uri = cJSON_GetObjectItemCaseSensitive(item, "uri");
|
|
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(item, "name");
|
|
cst_buf_printf(b, "[link %s%s%s]",
|
|
cJSON_IsString(uri) ? uri->valuestring : "",
|
|
cJSON_IsString(nm) ? " " : "",
|
|
cJSON_IsString(nm) ? nm->valuestring : "");
|
|
} else {
|
|
char *raw = cJSON_PrintUnformatted(item);
|
|
if (raw)
|
|
cst_buf_puts(b, raw);
|
|
free(raw);
|
|
}
|
|
}
|
|
|
|
static int set_out(cst_tool_result_t *r, int code, const char *fmt, ...)
|
|
__attribute__((format(printf, 3, 4)));
|
|
|
|
static int set_out(cst_tool_result_t *r, int code, const char *fmt, ...) {
|
|
cst_buf_t b = {0};
|
|
char tmp[1024];
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
vsnprintf(tmp, sizeof(tmp), fmt, ap);
|
|
va_end(ap);
|
|
cst_buf_puts(&b, tmp);
|
|
r->exit_code = code;
|
|
r->out_len = b.len;
|
|
r->out = cst_buf_steal(&b);
|
|
return 0;
|
|
}
|
|
|
|
static int mcp_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r, void *ud) {
|
|
(void)ctx;
|
|
mtool_t *mt = ud;
|
|
mcp_srv_t *s = mt->srv;
|
|
pthread_mutex_lock(&s->mu);
|
|
int st = s->state;
|
|
char serr[200];
|
|
snprintf(serr, sizeof(serr), "%s", s->error);
|
|
pthread_mutex_unlock(&s->mu);
|
|
if (st != CST_MCP_READY)
|
|
return set_out(r, 1, "error: MCP server %s is not running%s%s", s->name,
|
|
serr[0] ? ": " : "", serr);
|
|
char *aj = cst_tool_args_json(cst_tool_find(c->name), c);
|
|
cJSON *args = aj ? cJSON_Parse(aj) : NULL;
|
|
free(aj);
|
|
cJSON *params = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(params, "name", mt->orig);
|
|
cJSON_AddItemToObject(params, "arguments",
|
|
args ? args : cJSON_CreateObject());
|
|
char err[300];
|
|
cJSON *resp =
|
|
request(s, "tools/call", params, MCP_REQ_TIMEOUT_MS, err, sizeof(err));
|
|
if (!resp)
|
|
return set_out(r, 1, "error: %s: %s", s->name, err);
|
|
if (rpc_error(resp, err, sizeof(err))) {
|
|
cJSON_Delete(resp);
|
|
return set_out(r, 1, "error: %s: %s", s->name, err);
|
|
}
|
|
const cJSON *res = cJSON_GetObjectItemCaseSensitive(resp, "result");
|
|
const cJSON *content = cJSON_GetObjectItemCaseSensitive(res, "content");
|
|
cst_buf_t b = {0};
|
|
const cJSON *it = NULL;
|
|
cJSON_ArrayForEach(it, content) {
|
|
put_content(&b, it);
|
|
if (b.len > MCP_OUT_MAX)
|
|
break;
|
|
}
|
|
if (!b.len) {
|
|
const cJSON *sc = cJSON_GetObjectItemCaseSensitive(res, "structuredContent");
|
|
char *raw = sc ? cJSON_Print(sc) : NULL;
|
|
cst_buf_puts(&b, raw ? raw : "(no output)");
|
|
free(raw);
|
|
}
|
|
if (b.len > MCP_OUT_MAX) {
|
|
size_t cut = MCP_OUT_MAX;
|
|
while (cut > 0 && ((unsigned char)b.buf[cut] & 0xC0) == 0x80)
|
|
cut--;
|
|
size_t more = b.len - cut;
|
|
b.len = cut;
|
|
b.buf[cut] = 0;
|
|
cst_buf_printf(&b, "\n… [%zu more bytes cut]", more);
|
|
}
|
|
r->exit_code =
|
|
cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(res, "isError")) ? 1 : 0;
|
|
cJSON_Delete(resp);
|
|
r->out_len = b.len;
|
|
r->out = cst_buf_steal(&b);
|
|
return 0;
|
|
}
|
|
|
|
static void register_tool(mcp_srv_t *s, const cJSON *t) {
|
|
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(t, "name");
|
|
if (!cJSON_IsString(nm) || !nm->valuestring[0] ||
|
|
strlen(nm->valuestring) >= sizeof(((mtool_t *)0)->orig))
|
|
return;
|
|
char full[340], reg[CTL_MAX_NAME];
|
|
snprintf(full, sizeof(full), "mcp__%.63s__%.255s", s->name,
|
|
nm->valuestring);
|
|
cst_tool_sanitize_name(full, reg, sizeof(reg));
|
|
mtool_t *mt = calloc(1, sizeof(*mt));
|
|
if (!mt)
|
|
return;
|
|
mt->srv = s;
|
|
snprintf(mt->orig, sizeof(mt->orig), "%s", nm->valuestring);
|
|
const cJSON *desc = cJSON_GetObjectItemCaseSensitive(t, "description");
|
|
const cJSON *schema = cJSON_GetObjectItemCaseSensitive(t, "inputSchema");
|
|
char d[MCP_DESC_MAX + 8];
|
|
snprintf(d, sizeof(d), "%s", cJSON_IsString(desc) ? desc->valuestring : "");
|
|
if (cJSON_IsString(desc) && strlen(desc->valuestring) > MCP_DESC_MAX) {
|
|
size_t cut = MCP_DESC_MAX;
|
|
while (cut > 0 && ((unsigned char)d[cut] & 0xC0) == 0x80)
|
|
cut--;
|
|
snprintf(d + cut, sizeof(d) - cut, "…");
|
|
}
|
|
char *sj = cJSON_IsObject(schema)
|
|
? cJSON_PrintUnformatted(schema)
|
|
: strdup("{\"type\":\"object\",\"properties\":{}}");
|
|
cst_tool_def_t def;
|
|
memset(&def, 0, sizeof(def));
|
|
def.name = reg;
|
|
def.desc = d;
|
|
def.schema_json = sj;
|
|
def.perm = auto_approved(s, nm->valuestring) ? CST_TP_READ : CST_TP_NET;
|
|
def.run_ex = mcp_tool_run;
|
|
def.ud = mt;
|
|
def.owner = s->owner;
|
|
if (sj && cst_tool_register(&def) == 0) {
|
|
mt->next = s->tools;
|
|
s->tools = mt;
|
|
pthread_mutex_lock(&s->mu);
|
|
s->ntools++;
|
|
pthread_mutex_unlock(&s->mu);
|
|
} else {
|
|
free(mt);
|
|
}
|
|
free(sj);
|
|
}
|
|
|
|
static void *starter_main(void *arg) {
|
|
mcp_srv_t *s = arg;
|
|
char err[300];
|
|
int fd = os_mkstemp(s->err_log, sizeof(s->err_log), "cst-mcp-");
|
|
if (fd >= 0)
|
|
close(fd);
|
|
else
|
|
s->err_log[0] = 0;
|
|
const char *cwd = s->cwd[0] ? s->cwd : g_ws;
|
|
if (os_proc_spawn(cwd, (const char *const *)s->argv,
|
|
(const char *const *)s->env,
|
|
s->err_log[0] ? s->err_log : NULL, &s->proc) != 0) {
|
|
set_failed(s, CST_MCP_FAILED, "cannot start %s", s->argv[0]);
|
|
return NULL;
|
|
}
|
|
s->spawned = 1;
|
|
if (pthread_create(&s->reader, NULL, reader_main, s) != 0) {
|
|
set_failed(s, CST_MCP_FAILED, "cannot create the reader thread");
|
|
return NULL;
|
|
}
|
|
s->reader_on = 1;
|
|
|
|
cJSON *p = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(p, "protocolVersion", MCP_PROTOCOL);
|
|
cJSON *caps = cJSON_AddObjectToObject(p, "capabilities");
|
|
cJSON *roots = cJSON_AddObjectToObject(caps, "roots");
|
|
cJSON_AddFalseToObject(roots, "listChanged");
|
|
cJSON *ci = cJSON_AddObjectToObject(p, "clientInfo");
|
|
cJSON_AddStringToObject(ci, "name", "cst-agent");
|
|
cJSON_AddStringToObject(ci, "version", CST_VERSION);
|
|
cJSON *resp =
|
|
request(s, "initialize", p, MCP_INIT_TIMEOUT_MS, err, sizeof(err));
|
|
if (!resp || rpc_error(resp, err, sizeof(err))) {
|
|
cJSON_Delete(resp);
|
|
char tail[160];
|
|
stderr_tail(s, tail, sizeof(tail));
|
|
if (!atomic_load(&s->stopping))
|
|
set_failed(s, CST_MCP_FAILED, "initialize: %.150s%s%.120s", err,
|
|
tail[0] && !strstr(err, tail) ? " — " : "",
|
|
tail[0] && !strstr(err, tail) ? tail : "");
|
|
return NULL;
|
|
}
|
|
cJSON_Delete(resp);
|
|
notify(s, "notifications/initialized", NULL);
|
|
|
|
char cursor[512] = "";
|
|
for (int page = 0; page < 20; page++) {
|
|
cJSON *lp = cJSON_CreateObject();
|
|
if (cursor[0])
|
|
cJSON_AddStringToObject(lp, "cursor", cursor);
|
|
resp = request(s, "tools/list", lp, MCP_INIT_TIMEOUT_MS, err, sizeof(err));
|
|
if (!resp || rpc_error(resp, err, sizeof(err))) {
|
|
cJSON_Delete(resp);
|
|
if (!atomic_load(&s->stopping))
|
|
set_failed(s, CST_MCP_FAILED, "tools/list: %s", err);
|
|
return NULL;
|
|
}
|
|
const cJSON *res = cJSON_GetObjectItemCaseSensitive(resp, "result");
|
|
const cJSON *t = NULL;
|
|
cJSON_ArrayForEach(t, cJSON_GetObjectItemCaseSensitive(res, "tools"))
|
|
register_tool(s, t);
|
|
const cJSON *nc = cJSON_GetObjectItemCaseSensitive(res, "nextCursor");
|
|
int more = cJSON_IsString(nc) && nc->valuestring[0] &&
|
|
strlen(nc->valuestring) < sizeof(cursor);
|
|
if (more)
|
|
snprintf(cursor, sizeof(cursor), "%s", nc->valuestring);
|
|
cJSON_Delete(resp);
|
|
if (!more)
|
|
break;
|
|
}
|
|
pthread_mutex_lock(&s->mu);
|
|
if (s->state == CST_MCP_STARTING) {
|
|
s->state = CST_MCP_READY;
|
|
s->error[0] = 0;
|
|
}
|
|
pthread_cond_broadcast(&s->cv);
|
|
pthread_mutex_unlock(&s->mu);
|
|
return NULL;
|
|
}
|
|
|
|
/* ---------- lifecycle ---------- */
|
|
|
|
void cst_mcp_start_all(const char *ws) {
|
|
if (ws && ws[0])
|
|
snprintf(g_ws, sizeof(g_ws), "%s", ws);
|
|
for (int i = 0; i < g_nsrv; i++) {
|
|
mcp_srv_t *s = g_srv[i];
|
|
if (s->disabled || s->state != CST_MCP_OFF || !s->argv || !s->argv[0])
|
|
continue;
|
|
s->state = CST_MCP_STARTING;
|
|
if (pthread_create(&s->starter, NULL, starter_main, s) == 0)
|
|
s->starter_on = 1;
|
|
else
|
|
set_failed(s, CST_MCP_FAILED, "cannot create a thread");
|
|
}
|
|
}
|
|
|
|
void cst_mcp_wait_ready(int ms) {
|
|
double deadline = os_now_ms() + ms;
|
|
for (;;) {
|
|
int starting = 0;
|
|
for (int i = 0; i < g_nsrv; i++) {
|
|
pthread_mutex_lock(&g_srv[i]->mu);
|
|
starting += g_srv[i]->state == CST_MCP_STARTING;
|
|
pthread_mutex_unlock(&g_srv[i]->mu);
|
|
}
|
|
if (!starting || os_now_ms() > deadline)
|
|
return;
|
|
struct timespec ts = {0, 50 * 1000000L};
|
|
nanosleep(&ts, NULL);
|
|
}
|
|
}
|
|
|
|
void cst_mcp_stop_all(void) {
|
|
for (int i = 0; i < g_nsrv; i++) {
|
|
mcp_srv_t *s = g_srv[i];
|
|
atomic_store(&s->stopping, 1);
|
|
if (s->reader_on)
|
|
pthread_join(s->reader, NULL);
|
|
s->reader_on = 0;
|
|
if (s->spawned)
|
|
os_proc_kill(&s->proc);
|
|
s->spawned = 0;
|
|
if (s->starter_on)
|
|
pthread_join(s->starter, NULL);
|
|
s->starter_on = 0;
|
|
cst_tool_unregister_owner(s->owner);
|
|
if (s->err_log[0])
|
|
remove(s->err_log);
|
|
srv_free(s);
|
|
g_srv[i] = NULL;
|
|
}
|
|
g_nsrv = 0;
|
|
}
|
|
|
|
int cst_mcp_info(cst_mcp_info_t *out, int max) {
|
|
int n = g_nsrv < max ? g_nsrv : max;
|
|
for (int i = 0; i < n; i++) {
|
|
mcp_srv_t *s = g_srv[i];
|
|
cst_mcp_info_t *o = &out[i];
|
|
memset(o, 0, sizeof(*o));
|
|
snprintf(o->name, sizeof(o->name), "%s", s->name);
|
|
snprintf(o->source, sizeof(o->source), "%s", s->source);
|
|
snprintf(o->command, sizeof(o->command), "%s", s->command);
|
|
pthread_mutex_lock(&s->mu);
|
|
o->state = s->disabled ? CST_MCP_OFF : s->state;
|
|
o->ntools = s->ntools;
|
|
snprintf(o->error, sizeof(o->error), "%s", s->error);
|
|
pthread_mutex_unlock(&s->mu);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
void cst_mcp_counts(int *ready, int *total) {
|
|
int r = 0, t = 0;
|
|
for (int i = 0; i < g_nsrv; i++) {
|
|
if (g_srv[i]->disabled)
|
|
continue;
|
|
t++;
|
|
pthread_mutex_lock(&g_srv[i]->mu);
|
|
r += g_srv[i]->state == CST_MCP_READY;
|
|
pthread_mutex_unlock(&g_srv[i]->mu);
|
|
}
|
|
if (ready)
|
|
*ready = r;
|
|
if (total)
|
|
*total = t;
|
|
}
|