Release v0.4.0

This commit is contained in:
winlifes
2026-06-10 20:26:09 -07:00
parent 03e5ab8dfe
commit 6405dced7d
27 changed files with 1983 additions and 52 deletions
+135 -3
View File
@@ -84,7 +84,11 @@ class McpServer {
this.runtimeLog = options.runtimeLog;
this.serverName = options.serverName;
this.serverVersion = options.serverVersion;
this.projectName = options.projectName || '';
this.projectIdentity = options.projectIdentity || '';
this.server = null;
this.attached = false;
this.attachedInfo = null;
this.actualPort = null;
this.portFallbackInfo = null;
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
@@ -93,10 +97,13 @@ class McpServer {
}
isRunning() {
return Boolean(this.server && this.server.listening);
return Boolean(this.attached || (this.server && this.server.listening));
}
getPort() {
if (this.attached && this.actualPort) {
return this.actualPort;
}
if (this.server && typeof this.server.address === 'function') {
const address = this.server.address();
if (address && typeof address.port === 'number') {
@@ -114,6 +121,10 @@ class McpServer {
return this.portFallbackInfo;
}
getAttachInfo() {
return this.attachedInfo;
}
log(level, message) {
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
this.runtimeLog.add(level, message);
@@ -137,13 +148,21 @@ class McpServer {
this.actualPort = null;
this.portFallbackInfo = null;
this.attached = false;
this.attachedInfo = null;
const requestHandler = async (request, response) => {
try {
const requestUrl = new URL(request.url || '/', 'http://localhost');
if (request.method === 'GET' && requestUrl.pathname === '/health') {
this.log('info', 'GET /health');
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion);
return json(response, 200, {
ok: true,
name: this.serverName,
version: this.serverVersion,
projectName: this.projectName,
projectIdentity: this.projectIdentity,
}, this.negotiatedProtocolVersion);
}
if (request.method === 'GET' && requestUrl.pathname === '/tools') {
@@ -288,7 +307,12 @@ class McpServer {
return;
} catch (error) {
lastError = error;
candidate.removeAllListeners();
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
if (await this.tryAttachToExisting(port)) {
return;
}
const nextPort = port + 1;
this.log(
'warn',
@@ -299,7 +323,6 @@ class McpServer {
continue;
}
candidate.removeAllListeners();
break;
}
}
@@ -311,6 +334,15 @@ class McpServer {
}
async stop() {
if (this.attached) {
this.log('info', `Detached from existing MCP listener on ${this.config.host}:${this.actualPort}.`);
this.attached = false;
this.attachedInfo = null;
this.actualPort = null;
this.portFallbackInfo = null;
return;
}
if (!this.server) {
this.log('info', 'Stop skipped: server object is empty.');
return;
@@ -321,6 +353,7 @@ class McpServer {
this.server = null;
this.actualPort = null;
this.portFallbackInfo = null;
this.attachedInfo = null;
await new Promise((resolve, reject) => {
active.close((error) => {
if (error) {
@@ -334,6 +367,100 @@ class McpServer {
});
}
async tryAttachToExisting(port) {
if (!this.projectIdentity || this.config.attachToExisting === false || port === 0) {
return false;
}
const probe = await this.probeExistingServer(port);
if (!probe || !probe.result) {
this.log('warn', `Port ${port} is occupied, but no compatible Funplay MCP initialize response was received.`);
return false;
}
const result = probe.result || {};
const serverInfo = result.serverInfo || {};
const funplay = result.funplay || {};
const remoteProjectIdentity = funplay.projectIdentity || serverInfo.projectIdentity || '';
const remoteName = serverInfo.name || '';
if (remoteName === this.serverName && remoteProjectIdentity === this.projectIdentity) {
this.attached = true;
this.attachedInfo = {
host: this.config.host,
port,
serverName: remoteName,
projectName: funplay.projectName || this.projectName,
projectIdentity: remoteProjectIdentity,
version: serverInfo.version || '',
};
this.actualPort = port;
this.portFallbackInfo = null;
this.log('info', `Attached to existing MCP listener for this project at http://${this.config.host}:${port}/.`);
return true;
}
this.log(
'warn',
`Port ${port} belongs to another listener; expected name=${this.serverName}, project=${this.projectIdentity}, ` +
`got name=${remoteName || 'unknown'}, project=${remoteProjectIdentity || 'unknown'}.`
);
return false;
}
probeExistingServer(port) {
const body = JSON.stringify({
jsonrpc: '2.0',
id: 'funplay-probe',
method: 'initialize',
params: {
protocolVersion: MCP_PROTOCOL_VERSION,
clientInfo: {
name: 'funplay-cocos-mcp-probe',
version: this.serverVersion,
},
},
});
return new Promise((resolve) => {
const request = http.request(
{
host: this.config.host,
port,
method: 'POST',
path: '/',
timeout: 600,
headers: {
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
if (response.statusCode !== 200) {
resolve(null);
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (error) {
resolve(null);
}
});
}
);
request.on('timeout', () => {
request.destroy();
resolve(null);
});
request.on('error', () => resolve(null));
request.end(body);
});
}
listen(server, port, host) {
return new Promise((resolve, reject) => {
const onError = (error) => {
@@ -559,6 +686,11 @@ class McpServer {
name: this.serverName,
version: this.serverVersion,
},
funplay: {
server: 'funplay-cocos-mcp',
projectName: this.projectName,
projectIdentity: this.projectIdentity,
},
capabilities: {
tools: {},
resources: {},