From 26af815044ca3f549db1a4c5c0914ffd456b77cd Mon Sep 17 00:00:00 2001 From: nmaguiar Date: Fri, 28 Aug 2026 05:31:37 +0100 Subject: [PATCH 1/2] feat(mcp): implement MCP integration and add corresponding tests - Update openaf.js and owrap.server.js to support MCP functionality - Add new MCP test suite in autoTestAll.MCP.js and autoTestAll.MCP.yaml --- js/openaf.js | 93 ++++++++++- js/owrap.server.js | 326 +++++++++++++++++++++++++++++++++---- tests/autoTestAll.MCP.js | 125 ++++++++++++++ tests/autoTestAll.MCP.yaml | 30 ++++ 4 files changed, 544 insertions(+), 30 deletions(-) diff --git a/js/openaf.js b/js/openaf.js index 2896f86b1..6625aa869 100644 --- a/js/openaf.js +++ b/js/openaf.js @@ -9396,8 +9396,13 @@ const $jsonrpc = function (aOptions) { * * $mcp(aOptions) : Map * Creates a Model Context Protocol (MCP) client that can communicate with LLM MCP servers - * using JSON-RPC over stdio, remote connections, dummy mode, or oJob-based servers. This client implements the MCP protocol - * version 2024-11-05 and provides access to tools, prompts, and other MCP capabilities. + * using JSON-RPC over stdio, remote connections, dummy mode, or oJob-based servers. This client speaks the legacy, + * initialize-handshake-based MCP protocol eras by default (protocolVersion "2024-11-05" or "2025-06-18", selectable + * via aOptions.protocolVersion, default "2025-06-18") and provides access to tools, prompts, and other MCP + * capabilities. It can optionally opt into the modern, stateless 2026-07-28+ protocol era by setting + * aOptions.protocolVersion to "auto": this probes the server (server/discover on stdio, a modern request inspected + * for a recognized error on HTTP) and negotiates the modern era when the server supports it, falling back to the + * legacy initialize handshake otherwise -- existing callers that don't set "auto" are completely unaffected. * \ * The aOptions parameter is a map with the following possible keys:\ * \ @@ -9838,8 +9843,67 @@ const $mcp = function(aOptions) { throw new Error("Unsupported MCP auth.type: " + aOptions.auth.type) } + // Protocol version eras this client knows how to speak. Legacy (initialize-handshake-based) eras stay the + // default for every caller that doesn't opt into aOptions.protocolVersion == "auto"; the modern (stateless, + // per-request) era is only ever used after a successful _negotiateModernEra() probe below. + const _MCP_KNOWN_VERSIONS = ["2024-11-05", "2025-06-18", "2026-07-28"] + const _MCP_MODERN_THRESHOLD = "2026-07-28" + + // Probes the server for the modern (2026-07-28+) stateless protocol era via server/discover, per the spec's + // backward-compatibility guidance ("probe with server/discover and fall back on any error that is not a + // recognized modern error" on stdio; "attempt a modern request and inspect the response" on HTTP - this + // client's transport abstraction already normalizes both into the same result/error shape, so one code path + // covers both). Returns true and negotiates _r._era/_r._negotiatedVersion/_r._initResult on success; returns + // false (leaving _r untouched) on any response/error that doesn't identify a modern server, so the caller can + // fall back to the legacy initialize handshake exactly as it would without "auto". + const _negotiateModernEra = clientInfo => { + var _probeMeta = { + "io.modelcontextprotocol/protocolVersion": _MCP_MODERN_THRESHOLD, + "io.modelcontextprotocol/clientCapabilities": aOptions.capabilities, + "io.modelcontextprotocol/clientInfo": clientInfo + } + var _discover + try { + _discover = _execWithAuth("server/discover", { _meta: _probeMeta }, false, {}) + } catch (e) { + return false + } + var _versions = __ + if (isMap(_discover) && isArray(_discover.protocolVersions)) { + _versions = _discover.protocolVersions + } else if (isMap(_discover) && isMap(_discover.error) && isMap(_discover.error.data) && isArray(_discover.error.data.supported)) { + // UnsupportedProtocolVersionError is still a "recognized modern error": the server understood the + // request shape, it just doesn't support the version this probe declared. + _versions = _discover.error.data.supported + } + if (isUnDef(_versions)) return false + var _mutual = _versions.filter(v => _MCP_KNOWN_VERSIONS.indexOf(v) >= 0).sort() + if (_mutual.length == 0) return false + + _r._negotiatedVersion = _mutual[_mutual.length - 1] + _r._era = "modern" + _jsonrpc.setProtocolVersion(_r._negotiatedVersion) + _r._capabilities = (isMap(_discover) && isMap(_discover.capabilities)) ? _discover.capabilities : {} + _r._initResult = _buildSyntheticInitializeResult({ + protocolVersion: _r._negotiatedVersion, + serverInfo : isMap(_discover) ? _discover.serverInfo : __, + capabilities : _r._capabilities + }) + return true + } + const _execWithAuth = (method, params, notification, execOptions) => { execOptions = _$(execOptions, "execOptions").isMap().default({}) + // Modern era (2026-07-28+): every request stamps its protocol version/capabilities/identity in + // params._meta instead of relying on a prior initialize handshake -- there isn't one in this era. + if (_r._era == "modern") { + params = merge({}, isMap(params) ? params : {}) + params._meta = merge({ + "io.modelcontextprotocol/protocolVersion": _r._negotiatedVersion, + "io.modelcontextprotocol/clientCapabilities": aOptions.capabilities, + "io.modelcontextprotocol/clientInfo": aOptions.clientInfo + }, isMap(params._meta) ? params._meta : {}) + } var _isHttp = (aOptions.type == "remote" || aOptions.type == "http" || aOptions.type == "sse") if (_isHttp) { var _authHeaders = _getAuthHeaders() @@ -10101,6 +10165,8 @@ const $mcp = function(aOptions) { _initialized: false, _capabilities: {}, _initResult: __, + _era: __, + _negotiatedVersion: __, type: type => { _jsonrpc.type(type) return _r @@ -10117,6 +10183,20 @@ const $mcp = function(aOptions) { clientInfo = _$(clientInfo, "clientInfo").isMap().default({}) clientInfo = merge(aOptions.clientInfo, clientInfo) + // Modern era already negotiated: there's no handshake to repeat in this era. + if (_r._era == "modern") return _r + + if (aOptions.protocolVersion == "auto") { + if (_negotiateModernEra(clientInfo)) { + _r._initialized = true + return _r + } + // Not a modern server (or no usable response): fall back to the legacy initialize + // handshake below, using this client's default legacy version, exactly as a caller + // that never set "auto" would. + aOptions.protocolVersion = "2025-06-18" + } + var initResult = _execWithAuth("initialize", { protocolVersion: aOptions.protocolVersion, capabilities: aOptions.capabilities, @@ -10125,6 +10205,7 @@ const $mcp = function(aOptions) { if (isDef(initResult) && isUnDef(initResult.error)) { _r._initialized = true + _r._era = "legacy" _r._capabilities = initResult.capabilities || {} _r._initResult = initResult // Negotiated version: use whatever the server returned (even if unrecognised) for @@ -10158,8 +10239,16 @@ const $mcp = function(aOptions) { if (isMap(_r._initResult)) { _clientInfo.initialize = clone(_r._initResult) } + // era is "legacy" (initialize-handshake, the default) or "modern" (2026-07-28+ stateless, + // only ever reached via aOptions.protocolVersion == "auto") once initialize() has run. + _clientInfo.era = _r._era return _clientInfo }, + // True when result carries the modern era's InputRequiredResult shape (resultType "input_required"), + // meaning the server needs more information (sampling/elicitation/roots) before it can complete the + // request -- see the MRTR pattern. This client has no interactive UI to satisfy such a request; callers + // that talk to modern servers using those features should check for this and surface it to the user. + isInputRequired: result => isMap(result) && result.resultType == "input_required", listTools: () => { if (!_r._initialized) { throw new Error("MCP client not initialized. Call initialize() first.") diff --git a/js/owrap.server.js b/js/owrap.server.js index 97bcbe0cf..4f7550bac 100644 --- a/js/owrap.server.js +++ b/js/owrap.server.js @@ -1709,14 +1709,186 @@ OpenWrap.server.prototype.jsonRPC = function(data, mapOfFns) { } } +//----------------------------------------------------------------------------------------------------- +// MCP (Model Context Protocol) shared version negotiation +//----------------------------------------------------------------------------------------------------- + +/** + * + * ow.server.mcp + * Shared Model Context Protocol version negotiation helpers used by ow.server.mcpStdio, ow.server.httpd.replyJSONRPC/replyMCP + * and OpenAF's oJob-based MCP servers (httpdMCP/stdioMCP shortcuts). Implements the 2026-07-28 "modern" stateless + * per-request protocol era alongside the legacy initialize-handshake eras (2024-11-05, 2025-06-18) without changing + * any default (legacy) behavior -- modern-era handling is only exercised by call sites that opt into it. + * + */ +OpenWrap.server.prototype.mcp = { + versions: { + supported : ["2024-11-05", "2025-06-18", "2026-07-28"], + modernThreshold: "2026-07-28" + }, + + errorCodes: { + headerMismatch : -32020, + missingRequiredClientCapability: -32021, + unsupportedProtocolVersion : -32022, + invalidParams : -32602 + }, + + /** + * + * ow.server.mcp.isModern(aVersion) : boolean + * Returns true if aVersion is a protocol version string on or after the modern (stateless, 2026-07-28+) era. + * Protocol version strings are YYYY-MM-DD so lexicographic comparison is a valid chronological ordering. + * + */ + isModern: function(aVersion) { + return isString(aVersion) && aVersion >= this.versions.modernThreshold + }, + + /** + * + * ow.server.mcp.requestMeta(reqObj) : Map + * Returns the request's params._meta map (per the spec, _meta is nested inside params, not a top-level + * JSON-RPC envelope field), or __ if not present. + * + */ + requestMeta: function(reqObj) { + if (!isMap(reqObj) || !isMap(reqObj.params) || !isMap(reqObj.params._meta)) return __ + return reqObj.params._meta + }, + + /** + * + * ow.server.mcp.metaProtocolVersion(reqObj) : String + * Extracts the modern per-request protocol version from a parsed JSON-RPC request's params._meta field + * (io.modelcontextprotocol/protocolVersion), or __ if not present. + * + */ + metaProtocolVersion: function(reqObj) { + var _meta = this.requestMeta(reqObj) + if (isUnDef(_meta)) return __ + var v = _meta["io.modelcontextprotocol/protocolVersion"] + return isString(v) ? v : __ + }, + + /** + * + * ow.server.mcp.metaClientCapabilities(reqObj) : Map + * Extracts the per-request client capabilities from params._meta["io.modelcontextprotocol/clientCapabilities"], + * or __ if not present. + * + */ + metaClientCapabilities: function(reqObj) { + var _meta = this.requestMeta(reqObj) + if (isUnDef(_meta)) return __ + var c = _meta["io.modelcontextprotocol/clientCapabilities"] + return isMap(c) ? c : __ + }, + + /** + * + * ow.server.mcp.eraOf(reqObj) : String + * Classifies a parsed JSON-RPC request as "modern" (carries a modern params._meta protocol version), + * "legacy-initialize" (an initialize handshake request) or "legacy-stateless" (any other request, handled + * statelessly per today's default behavior). + * + */ + eraOf: function(reqObj) { + if (isDef(this.metaProtocolVersion(reqObj))) return "modern" + if (isMap(reqObj) && reqObj.method == "initialize") return "legacy-initialize" + return "legacy-stateless" + }, + + /** + * + * ow.server.mcp.unsupportedVersionError(anId, aRequestedVersion) : Map + * Builds a JSON-RPC UnsupportedProtocolVersionError (-32022) response listing the versions this server supports. + * + */ + unsupportedVersionError: function(anId, aRequestedVersion) { + return { + jsonrpc: "2.0", + id : isDef(anId) ? anId : null, + error : { + code : this.errorCodes.unsupportedProtocolVersion, + message: "Unsupported protocol version", + data : { supported: this.versions.supported, requested: aRequestedVersion } + } + } + }, + + /** + * + * ow.server.mcp.invalidParamsError(anId, aMessage, aData) : Map + * Builds a JSON-RPC Invalid Params (-32602) error response, used when a modern-era request is missing a + * required params._meta field (e.g. io.modelcontextprotocol/clientCapabilities). + * + */ + invalidParamsError: function(anId, aMessage, aData) { + var _err = { code: this.errorCodes.invalidParams, message: _$(aMessage, "aMessage").isString().default("Invalid params") } + if (isDef(aData)) _err.data = aData + return { jsonrpc: "2.0", id: isDef(anId) ? anId : null, error: _err } + }, + + /** + * + * ow.server.mcp.wrapResult(aBody, anEra, opts) : Map + * Wraps a MCP result body per the negotiated era. Legacy eras ("legacy-initialize"/"legacy-stateless") return + * aBody unchanged -- zero behavior change for existing callers. The "modern" era merges in the required + * resultType ("complete"), and, when opts.isList is true, CacheableResult fields (ttlMs, cacheScope) with + * sensible defaults (opts.ttlMs default 0, opts.cacheScope default "private"). When opts.serverInfo is given, + * it is advertised (SHOULD, per spec) as result._meta["io.modelcontextprotocol/serverInfo"]. + * + */ + wrapResult: function(aBody, anEra, opts) { + if (anEra != "modern" || !isMap(aBody)) return aBody + opts = isMap(opts) ? opts : {} + var _res = merge({ resultType: "complete" }, aBody) + if (opts.isList === true) { + _res = merge({ + ttlMs : isNumber(opts.ttlMs) ? opts.ttlMs : 0, + cacheScope: isString(opts.cacheScope) ? opts.cacheScope : "private" + }, _res) + } + if (isMap(opts.serverInfo)) { + _res._meta = merge({ "io.modelcontextprotocol/serverInfo": opts.serverInfo }, isMap(_res._meta) ? _res._meta : {}) + } + return _res + }, + + /** + * + * ow.server.mcp.discoverResult(initData) : Map + * Builds the mandatory server/discover response (modern era) advertising this server's supported protocol + * versions, capabilities and identity. + * + */ + discoverResult: function(initData) { + initData = isMap(initData) ? initData : {} + return { + resultType : "complete", + protocolVersions: this.versions.supported, + capabilities : initData.capabilities || {}, + serverInfo : initData.serverInfo || {} + } + } +} + /** * - * ow.server.mcpStdio(initData, fnsMeta, fns, lgF) + * ow.server.mcpStdio(initData, fnsMeta, fns, lgF, opts) * Processes a MCP (Model Context Protocol) request using standard input/output. The initData is a map with initial data to be sent to the client, fnsMeta is an array of function metadata and fns is a map of functions to be executed. * The lgF is a function that will be used to log messages. If not provided, it will default to a function that writes logs to a file named "log.ndjson". * The initData should contain the server information and capabilities. The fnsMeta should contain metadata about the functions available, such as their names and descriptions. The fns should contain the actual functions that can be called by the client. * The function will listen for incoming MCP requests on standard input and respond accordingly.\ * \ + * opts is an optional map: { modern: false } (default). When modern is true this also serves the 2026-07-28\ + * stateless/per-request MCP protocol era (per-request _meta protocol version, server/discover, resultType,\ + * ttlMs/cacheScope on list results, UnsupportedProtocolVersionError) side-by-side with the legacy\ + * initialize-handshake era used above, for any request that carries a modern _meta protocol version.\ + * When modern is false (the default) behavior is 100% unchanged from previous releases.\ + * \ * Example usage:\ * \ * ow.server.mcpStdio({ @@ -1762,7 +1934,7 @@ OpenWrap.server.prototype.jsonRPC = function(data, mapOfFns) { * }) * */ -OpenWrap.server.prototype.mcpStdio = function(initData, fnsMeta, fns, lgF) { +OpenWrap.server.prototype.mcpStdio = function(initData, fnsMeta, fns, lgF, opts) { var _mcpResultToText = result => (__flags.MCPSERVER && __flags.MCPSERVER.answerInTOON === true) ? af.toTOON(result) : stringify(result) lgF = _$(lgF, "lgF").isFunction().default((t, m) => { @@ -1773,6 +1945,10 @@ OpenWrap.server.prototype.mcpStdio = function(initData, fnsMeta, fns, lgF) { initData = _$(initData, "initData").isMap().default({}) _$(fnsMeta, "fnsMeta").isArray().$_() _$(fns, "fns").isMap().$_() + // opts.modern (default false) opts into the 2026-07-28 stateless/per-request protocol era on top of the + // legacy initialize-handshake behavior below, which is left fully unchanged when opts.modern is not set. + opts = _$(opts, "opts").isMap().default({}) + opts.modern = _$(opts.modern, "opts.modern").isBoolean().default(false) initData = merge({ protocolVersion: "2025-06-18", serverInfo: { @@ -1799,43 +1975,72 @@ OpenWrap.server.prototype.mcpStdio = function(initData, fnsMeta, fns, lgF) { return } lgF("rcv", _pline) - var _res = ow.server.jsonRPC(_pline, { + + // Era is only ever "modern" when this server opted in; otherwise every request is handled + // exactly as before (legacy-stateless), regardless of what shape the request happens to have. + var _era = opts.modern ? ow.server.mcp.eraOf(_pline) : "legacy-stateless" + if (_era == "modern") { + var _reqVersion = ow.server.mcp.metaProtocolVersion(_pline) + var _sendErr = _errRes => { + if (isDef(_pline.id) && !isNull(_pline.id)) { + lgF("snd", _errRes) + sprint(_errRes, "") + } + } + if (ow.server.mcp.versions.supported.indexOf(_reqVersion) < 0) { + _sendErr(ow.server.mcp.unsupportedVersionError(_pline.id, _reqVersion)) + return + } + // server/discover is the bootstrap/probe method (per spec, clients call it before they know what to + // declare) and is exempt from the clientCapabilities requirement -- a probing client must be able to + // get back a DiscoverResult or UnsupportedProtocolVersionError, the only two "recognized modern" + // responses it knows to treat as "this is a modern server", not an unrelated -32602. + if (_pline.method != "server/discover" && isUnDef(ow.server.mcp.metaClientCapabilities(_pline))) { + _sendErr(ow.server.mcp.invalidParamsError(_pline.id, "Missing required params._meta[\"io.modelcontextprotocol/clientCapabilities\"]")) + return + } + } + // Wraps a result per the negotiated era (no-op for legacy eras) and, for the modern era, advertises this + // server's identity on every result per the spec's per-response protocol fields. + var _wrap = (aBody, isList) => ow.server.mcp.wrapResult(aBody, _era, { isList: isList === true, serverInfo: initData.serverInfo }) + + var _res = ow.server.jsonRPC(_pline, merge({ initialize : params => (isMap(params) && isString(params.protocolVersion)) ? merge(initData, { protocolVersion: params.protocolVersion }) : initData, - "prompts/list" : () => ({ prompts: [] }), + "prompts/list" : () => _wrap({ prompts: [] }, true), "notifications/initialized": () => ({}), ping : () => ({}), "tools/call" : params => { if (isUnDef(params) || isUnDef(params.name)) { - return { content: [{ type: "text", text: "Missing tool name" }], isError: true } + return _wrap({ content: [{ type: "text", text: "Missing tool name" }], isError: true }) } const tool = fns[params.name] if (tool) { try { var result = tool(params.input || params.arguments || {}) - return { + return _wrap({ content: [{ type: "text", text: isString(result) ? result : _mcpResultToText(result) }], isError: false - } + }) } catch (e) { - return { + return _wrap({ content: [{ type: "text", text: "Error executing tool: " + e.message }], isError: true - } + }) } } else { - return { content: [{ + return _wrap({ content: [{ type: "text", text: "Tool not found: " + params.name - }], isError: true } + }], isError: true }) } }, - "tools/list": () => ({ tools: fnsMeta }) + "tools/list": () => _wrap({ tools: fnsMeta }, true) /*"agents/list": params => { if (isUnDef(global.__a2a__)) { return { agents: [] } @@ -1866,7 +2071,9 @@ OpenWrap.server.prototype.mcpStdio = function(initData, fnsMeta, fns, lgF) { } return global.__a2a__.send(params.id, params.message, params.options || {}, params.context || {}) }*/ - }) + }, opts.modern ? { + "server/discover": () => ow.server.mcp.discoverResult(initData) + } : {})) // JSON-RPC notifications return null by design; don't emit a stray "null" line on stdio. if (isDef(_res) && !isNull(_res)) { @@ -3649,7 +3856,7 @@ OpenWrap.server.prototype.httpd = { /** * - * ow.server.httpd.replyJSONRPC(server, request, mapOfFunctions, logFn, debugFn) : Map + * ow.server.httpd.replyJSONRPC(server, request, mapOfFunctions, logFn, debugFn, opts) : Map * Implements a JSON-RPC 2.0 endpoint using the provided mapOfFunctions. The request must be a POST with a JSON-RPC body.\ * Optionally you can provide logFn and debugFn functions to log errors and debug information respectively.\ * \ @@ -3657,12 +3864,24 @@ OpenWrap.server.prototype.httpd = { * ow.server.httpd.route(hs, {\ * "/rpc": (req) => ow.server.httpd.replyJSONRPC(hs, req, { sum: (a, b) => a + b }, logErr, log) \ * })\ - * + * \ + * opts is an optional map: { modern: false, listMethods: [...] } (defaults shown). When modern is true,\ + * requests carrying a modern (2026-07-28+) per-request _meta protocol version are validated against\ + * ow.server.mcp.versions.supported (replying with an UnsupportedProtocolVersionError otherwise), results are\ + * wrapped with the required resultType (plus ttlMs/cacheScope for methods listed in opts.listMethods, default\ + * ["tools/list","prompts/list","resources/list","resources/templates/list"]), and the negotiated version is\ + * echoed back via the MCP-Protocol-Version response header. Legacy requests (the default, and everything when\ + * modern is false/omitted) are handled exactly as before -- zero behavior change.\ + * * */ - replyJSONRPC: function(server, request, mapOfFunctions, logFn, debugFn) { + replyJSONRPC: function(server, request, mapOfFunctions, logFn, debugFn, opts) { logFn = _$(logFn, "logFn").isFunction().default(log) debugFn = _$(debugFn, "debugFn").isFunction().default(() => {}) + opts = _$(opts, "opts").isMap().default({}) + opts.modern = _$(opts.modern, "opts.modern").isBoolean().default(false) + opts.listMethods = _$(opts.listMethods, "opts.listMethods").isArray().default(["tools/list", "prompts/list", "resources/list", "resources/templates/list"]) + opts.serverInfo = _$(opts.serverInfo, "opts.serverInfo").isMap().default(__) try { if (request.method !== "POST") { @@ -3702,6 +3921,26 @@ OpenWrap.server.prototype.httpd = { id: reqObj && reqObj.id !== undefined ? reqObj.id : null }, 400, "application/json", {}) } + // Era is only ever "modern" when this server opted in; otherwise every request is handled + // exactly as before (legacy-stateless), regardless of what shape the request happens to have. + var _era = opts.modern ? ow.server.mcp.eraOf(reqObj) : "legacy-stateless" + var _respHeaders = {} + if (_era == "modern") { + var _reqVersion = ow.server.mcp.metaProtocolVersion(reqObj) + if (ow.server.mcp.versions.supported.indexOf(_reqVersion) < 0) { + logFn("Invalid JSON-RPC request: " + request.method + " " + request.uri + " - Unsupported protocol version: " + _reqVersion) + return ow.server.httpd.reply(ow.server.mcp.unsupportedVersionError(reqObj.id, _reqVersion), 400, "application/json", {}) + } + // server/discover is the bootstrap/probe method (per spec, clients call it before they know what to + // declare) and is exempt from the clientCapabilities requirement -- a probing client must be able + // to get back a DiscoverResult or UnsupportedProtocolVersionError, the only two "recognized modern" + // responses it knows to treat as "this is a modern server", not an unrelated -32602. + if (reqObj.method != "server/discover" && isUnDef(ow.server.mcp.metaClientCapabilities(reqObj))) { + logFn("Invalid JSON-RPC request: " + request.method + " " + request.uri + " - Missing required client capabilities") + return ow.server.httpd.reply(ow.server.mcp.invalidParamsError(reqObj.id, "Missing required params._meta[\"io.modelcontextprotocol/clientCapabilities\"]"), 400, "application/json", {}) + } + _respHeaders["MCP-Protocol-Version"] = _reqVersion + } // JSON-RPC notification (no id): the Streamable HTTP transport requires a bare // 202 Accepted with no body - HTTP 404 is reserved for an expired session if (isUnDef(reqObj.id) || isNull(reqObj.id)) { @@ -3709,7 +3948,7 @@ OpenWrap.server.prototype.httpd = { if (isFunction(nfn)) { try { nfn(reqObj.params) } catch(e) { logFn("Error handling notification " + reqObj.method + ": " + String(e)) } } - return ow.server.httpd.reply("", 202, "text/plain", {}) + return ow.server.httpd.reply("", 202, "text/plain", _respHeaders) } var fn = mapOfFunctions[reqObj.method] if (!isFunction(fn)) { @@ -3721,23 +3960,24 @@ OpenWrap.server.prototype.httpd = { jsonrpc: "2.0", error: { code: -32601, message: "Method not found" }, id: reqObj.id - }, 200, "application/json", {}) + }, 200, "application/json", _respHeaders) } try { var result = isArray(reqObj.params) ? fn.apply(null, reqObj.params) : fn(reqObj.params) + result = ow.server.mcp.wrapResult(result, _era, { isList: opts.listMethods.indexOf(reqObj.method) >= 0, serverInfo: opts.serverInfo }) debugFn("JSON-RPC request result: " + stringify(result)); return ow.server.httpd.reply({ jsonrpc: "2.0", result: result, id: reqObj.id - }, 200, "application/json", {}) + }, 200, "application/json", _respHeaders) } catch(e) { logFn("Invalid JSON-RPC request: " + request.method + " " + request.uri + " - Internal error: " + String(e)) return ow.server.httpd.reply({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error", data: String(e) }, id: reqObj.id - }, 500, "application/json", {}) + }, 500, "application/json", _respHeaders) } } catch(e) { logFn("Invalid JSON-RPC request: " + request.method + " " + request.uri + " - Internal error: " + String(e)) @@ -3751,7 +3991,7 @@ OpenWrap.server.prototype.httpd = { /** * - * ow.server.httpd.replyMCP(server, request, mapOfFunctions, logFn, debugFn) : Map + * ow.server.httpd.replyMCP(server, request, mapOfFunctions, logFn, debugFn, opts) : Map * Implements a Model Context Protocol (MCP) endpoint using the provided mapOfFunctions. The request must be a POST with a MCP body.\ * Optionally you can provide logFn and debugFn functions to log errors and debug information respectively.\ * \ @@ -3766,12 +4006,21 @@ OpenWrap.server.prototype.httpd = { * }, logErr, log),\ * "/echo": req => ow.server.httpd.reply(stringify(req))\ * }) + * \ + * opts is an optional map: { modern: false, listMethods: [...] } -- see ow.server.httpd.replyJSONRPC for the\ + * meaning of both; the same opt-in 2026-07-28 modern-era handling (version validation, resultType/ttlMs/\ + * cacheScope wrapping, MCP-Protocol-Version response header) applies here, with legacy behavior (default)\ + * left fully unchanged.\ * * */ - replyMCP: function(server, request, mapOfFunctions, logFn, debugFn) { + replyMCP: function(server, request, mapOfFunctions, logFn, debugFn, opts) { logFn = _$(logFn, "logFn").isFunction().default(function() {}) debugFn = _$(debugFn, "debugFn").isFunction().default(function() {}) + opts = _$(opts, "opts").isMap().default({}) + opts.modern = _$(opts.modern, "opts.modern").isBoolean().default(false) + opts.listMethods = _$(opts.listMethods, "opts.listMethods").isArray().default(["tools/list", "prompts/list", "resources/list", "resources/templates/list"]) + opts.serverInfo = _$(opts.serverInfo, "opts.serverInfo").isMap().default(__) try { debugFn("Processing MCP request: " + stringify(request)) if (request.method !== "POST") { @@ -3811,34 +4060,55 @@ OpenWrap.server.prototype.httpd = { id: reqObj && reqObj.id !== undefined ? reqObj.id : null }, 200, "application/json", {}) } + // Era is only ever "modern" when this server opted in; otherwise every request is handled + // exactly as before (legacy-stateless), regardless of what shape the request happens to have. + var _era = opts.modern ? ow.server.mcp.eraOf(reqObj) : "legacy-stateless" + var _respHeaders = {} + if (_era == "modern") { + var _reqVersion = ow.server.mcp.metaProtocolVersion(reqObj) + if (ow.server.mcp.versions.supported.indexOf(_reqVersion) < 0) { + logFn("Invalid MCP request: " + request.method + " " + request.uri + " - Unsupported protocol version: " + _reqVersion) + return ow.server.httpd.reply(ow.server.mcp.unsupportedVersionError(reqObj.id, _reqVersion), 400, "application/json", {}) + } + // server/discover is the bootstrap/probe method (per spec, clients call it before they know what to + // declare) and is exempt from the clientCapabilities requirement -- a probing client must be able + // to get back a DiscoverResult or UnsupportedProtocolVersionError, the only two "recognized modern" + // responses it knows to treat as "this is a modern server", not an unrelated -32602. + if (reqObj.method != "server/discover" && isUnDef(ow.server.mcp.metaClientCapabilities(reqObj))) { + logFn("Invalid MCP request: " + request.method + " " + request.uri + " - Missing required client capabilities") + return ow.server.httpd.reply(ow.server.mcp.invalidParamsError(reqObj.id, "Missing required params._meta[\"io.modelcontextprotocol/clientCapabilities\"]"), 400, "application/json", {}) + } + _respHeaders["MCP-Protocol-Version"] = _reqVersion + } var isNotification = isUnDef(reqObj.id) || isNull(reqObj.id) var fn = mapOfFunctions[reqObj.method] if (!isFunction(fn)) { logFn("Invalid MCP request: " + request.method + " " + request.uri + " - Method not found: " + reqObj.method) - if (isNotification) return ow.server.httpd.reply("", 204, "text/plain", {}) + if (isNotification) return ow.server.httpd.reply("", 204, "text/plain", _respHeaders) return ow.server.httpd.reply({ jsonrpc: "2.0", error: { code: -32601, message: "Method not found" }, id: reqObj.id - }, 200, "application/json", {}) + }, 200, "application/json", _respHeaders) } try { var result = isArray(reqObj.params) ? fn.apply(null, reqObj.params) : fn(reqObj.params) + result = ow.server.mcp.wrapResult(result, _era, { isList: opts.listMethods.indexOf(reqObj.method) >= 0, serverInfo: opts.serverInfo }) debugFn("MCP request result: " + stringify(result)) - if (isNotification) return ow.server.httpd.reply("", 204, "text/plain", {}) + if (isNotification) return ow.server.httpd.reply("", 204, "text/plain", _respHeaders) return ow.server.httpd.reply({ jsonrpc: "2.0", result: result, id: reqObj.id - }, 200, "application/json", {}) + }, 200, "application/json", _respHeaders) } catch(e) { logFn("Invalid MCP request: " + request.method + " " + request.uri + " - Internal error: " + String(e)) - if (isNotification) return ow.server.httpd.reply("", 204, "text/plain", {}) + if (isNotification) return ow.server.httpd.reply("", 204, "text/plain", _respHeaders) return ow.server.httpd.reply({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error", data: String(e) }, id: reqObj.id - }, 200, "application/json", {}) + }, 200, "application/json", _respHeaders) } } catch(e) { logFn("Invalid MCP request: " + request.method + " " + request.uri + " - Internal error: " + String(e)) diff --git a/tests/autoTestAll.MCP.js b/tests/autoTestAll.MCP.js index 8cc03dd55..d336ee50d 100644 --- a/tests/autoTestAll.MCP.js +++ b/tests/autoTestAll.MCP.js @@ -264,6 +264,131 @@ }); }; + // Dual-era HTTP MCP server fixture: exercises the real ow.server.httpd.replyJSONRPC(..., {modern:true}) + // code path added for the 2026-07-28 protocol era, side-by-side with the legacy initialize handshake. + var withDualEraMCPServer = function(testFn) { + ow.loadServer(); + + var port = findRandomOpenPort(); + var hs = ow.server.httpd.start(port, "127.0.0.1"); + var description = { + serverInfo: { name: "Dual-Era MCP", version: "1.0.0" }, + capabilities: { tools: { listChanged: false } } + }; + var rpcFns = { + initialize: params => merge({ protocolVersion: "2025-06-18" }, description), + "notifications/initialized": () => ({}), + "tools/list": () => ({ tools: [{ name: "ping", description: "Ping tool", inputSchema: { type: "object", properties: {} } }] }), + "tools/call": params => ({ content: [{ type: "text", text: "pong" }], isError: false }), + "server/discover": () => ow.server.mcp.discoverResult(description) + }; + + ow.server.httpd.route(hs, { + "/mcp": req => ow.server.httpd.replyJSONRPC(hs, req, rpcFns, function() {}, function() {}, { modern: true, serverInfo: description.serverInfo }) + }); + + try { + testFn({ port: port, hs: hs, url: "http://127.0.0.1:" + port + "/mcp" }); + } finally { + ow.server.httpd.stop(hs); + } + }; + + exports.testMCPAutoClientNegotiatesModernEraWithDualEraServer = function() { + withDualEraMCPServer(function(ctx) { + var client = $mcp({ type: "remote", strict: false, url: ctx.url, protocolVersion: "auto" }); + try { + client.initialize({ name: "TestClient", version: "9.9.9" }); + ow.test.assert(client.getClientInfo().era, "modern", "protocolVersion:'auto' should negotiate the modern era against a dual-era server."); + + var tools = client.listTools(); + ow.test.assert(tools.tools[0].name, "ping", "Modern-era client should still list tools normally."); + ow.test.assert(tools.resultType, "complete", "Modern-era tools/list result should carry resultType."); + ow.test.assert(isNumber(tools.ttlMs), true, "Modern-era tools/list result should carry ttlMs (CacheableResult)."); + ow.test.assert(isString(tools.cacheScope), true, "Modern-era tools/list result should carry cacheScope (CacheableResult)."); + + var res = client.callTool("ping", {}); + ow.test.assert(res.content[0].text, "pong", "Modern-era callTool should still work."); + ow.test.assert(res.resultType, "complete", "Modern-era tools/call result should carry resultType."); + } finally { + client.destroy(); + } + }); + }; + + exports.testMCPAutoClientFallsBackToLegacyServer = function() { + withOAuthMCPServer(function(ctx) { + // This fixture server (see withOAuthMCPServer above) has no server/discover method and answers + // initialize with a fixed 2025-06-18, i.e. exactly the legacy-only server this task must not regress. + var client = $mcp({ type: "remote", strict: false, url: ctx.resource, protocolVersion: "auto" }); + try { + client.initialize({ name: "TestClient", version: "9.9.9" }); + ow.test.assert(client.getClientInfo().era, "legacy", "protocolVersion:'auto' should fall back to the legacy era against a legacy-only server."); + + var tools = client.listTools(); + ow.test.assert(tools.tools[0].name, "ping", "Legacy fallback should still list tools normally."); + ow.test.assert(isUnDef(tools.resultType), true, "Legacy-era results must stay unwrapped (no resultType) -- zero behavior change."); + } finally { + client.destroy(); + } + }); + }; + + exports.testMCPLegacyClientUnaffectedByDualEraServer = function() { + withDualEraMCPServer(function(ctx) { + // A plain (non-"auto") client is the overwhelming majority case today: it must behave identically + // whether or not the server it talks to has opted into modern-era support. + var client = $mcp({ type: "remote", strict: false, url: ctx.url }); + try { + client.initialize({ name: "TestClient", version: "9.9.9" }); + ow.test.assert(client.getClientInfo().era, "legacy", "Default client should stay on the legacy era even against a dual-era server."); + + var tools = client.listTools(); + ow.test.assert(tools.tools[0].name, "ping", "Legacy client should list tools normally against a dual-era server."); + ow.test.assert(isUnDef(tools.resultType), true, "Legacy client's result must stay unwrapped against a dual-era server."); + } finally { + client.destroy(); + } + }); + }; + + exports.testMCPModernServerRejectsUnsupportedProtocolVersion = function() { + withDualEraMCPServer(function(ctx) { + var client = $mcp({ type: "remote", strict: false, url: ctx.url }); + try { + var res = client.exec("tools/list", { + _meta: { + "io.modelcontextprotocol/protocolVersion": "1900-01-01", + "io.modelcontextprotocol/clientCapabilities": {} + } + }); + ow.test.assert(isMap(res.error), true, "An unsupported modern protocol version should return a JSON-RPC error."); + ow.test.assert(res.error.code, -32022, "Unsupported protocol version should use the reserved -32022 error code."); + ow.test.assert(res.error.data.requested, "1900-01-01", "UnsupportedProtocolVersionError should echo the requested version."); + ow.test.assert(res.error.data.supported.indexOf("2026-07-28") >= 0, true, "UnsupportedProtocolVersionError should list this server's supported versions."); + } finally { + client.destroy(); + } + }); + }; + + exports.testMCPServerDiscoverIsProbeableWithoutClientCapabilities = function() { + withDualEraMCPServer(function(ctx) { + // A client probing era support (per spec) may call server/discover before it knows what capabilities + // to declare; server/discover must not itself require io.modelcontextprotocol/clientCapabilities. + var client = $mcp({ type: "remote", strict: false, url: ctx.url }); + try { + var res = client.exec("server/discover", { + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } + }); + ow.test.assert(isDef(res.error), false, "A bare server/discover probe (no clientCapabilities yet) must not be rejected."); + ow.test.assert(res.protocolVersions.indexOf("2026-07-28") >= 0, true, "server/discover should advertise the modern protocol version."); + } finally { + client.destroy(); + } + }); + }; + exports.testOJobTplDescRendersAllToolMetadataStrings = function() { var tf = "ojob_mcp_tpldesc_" + genUUID() + ".yaml"; diff --git a/tests/autoTestAll.MCP.yaml b/tests/autoTestAll.MCP.yaml index c5f25464c..5e915d4a3 100644 --- a/tests/autoTestAll.MCP.yaml +++ b/tests/autoTestAll.MCP.yaml @@ -33,9 +33,39 @@ jobs: to : oJob Test exec: args.func = args.tests.testOJobTplDescRendersAllToolMetadataStrings; + - name: MCP::auto client negotiates modern era with dual-era server + from: MCP::Init + to : oJob Test + exec: args.func = args.tests.testMCPAutoClientNegotiatesModernEraWithDualEraServer; + + - name: MCP::auto client falls back to legacy server + from: MCP::Init + to : oJob Test + exec: args.func = args.tests.testMCPAutoClientFallsBackToLegacyServer; + + - name: MCP::legacy client unaffected by dual-era server + from: MCP::Init + to : oJob Test + exec: args.func = args.tests.testMCPLegacyClientUnaffectedByDualEraServer; + + - name: MCP::modern server rejects unsupported protocol version + from: MCP::Init + to : oJob Test + exec: args.func = args.tests.testMCPModernServerRejectsUnsupportedProtocolVersion; + + - name: MCP::server discover is probeable without client capabilities + from: MCP::Init + to : oJob Test + exec: args.func = args.tests.testMCPServerDiscoverIsProbeableWithoutClientCapabilities; + todo: - MCP::OAuth discovery with client credentials - MCP::OAuth authorization URL includes resource and PKCE - MCP::OAuth authorization code token exchange includes resource and verifier - MCP::Remote clients close request HTTP clients - MCP::oJob tplDesc renders all tool metadata strings + - MCP::auto client negotiates modern era with dual-era server + - MCP::auto client falls back to legacy server + - MCP::legacy client unaffected by dual-era server + - MCP::modern server rejects unsupported protocol version + - MCP::server discover is probeable without client capabilities From accb0a76886b15164c6568fa9a41a10d5af8fd58 Mon Sep 17 00:00:00 2001 From: nmaguiar Date: Sun, 30 Aug 2026 15:50:18 +0100 Subject: [PATCH 2/2] feat(scripts): update script generation logic in genScripts.js Modified js/genScripts.js with 15 insertions and 4 deletions to improve script generation. --- js/genScripts.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/js/genScripts.js b/js/genScripts.js index 2cf37a5b1..bf98f0367 100644 --- a/js/genScripts.js +++ b/js/genScripts.js @@ -223,18 +223,29 @@ function generateWinConsoleBat() { // Returns a sh snippet that keeps a copy of the current terminal settings and // restores them whenever the script exits (normally or interrupted). jline uses -// /dev/tty (and not stdin) so the same device is used here. +// /dev/tty (and not stdin) so the same device is used here. If an exact state +// snapshot is unavailable, a launcher that changed the terminal falls back to +// `stty sane` so it cannot leave the invoking shell unusable. function genUnixSttyRestore() { var s; s = "__oaf_stty=`stty -g 2>/dev/null /dev/null /dev/null