Release v0.4.0
This commit is contained in:
+135
-3
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user