From ff693c13212fc0161007a6cf68c40dc792d486c0 Mon Sep 17 00:00:00 2001 From: Sam Clemente Date: Sun, 30 Aug 2026 20:35:00 -0500 Subject: [PATCH 1/2] feat: harden the agent-native skill runtime Unifies discovery behind three canonical tools, adds standard Agent Skills packages and sidecar policy, and makes protocol negotiation, package resources, assignments, feedback, and persistence deterministic and truthful. --- .../BACKEND_API_CONTRACT.md | 12 +- services/mcp-gateway/Package.resolved | 5 +- services/mcp-gateway/Package.swift | 2 +- .../App/Controllers/MCPController.swift | 341 +++++++++++--- .../App/Controllers/McpSseController.swift | 3 + .../App/Controllers/ProjectController.swift | 145 ++++-- .../App/MCP/CapabilitySchemaBuilder.swift | 192 ++++++-- .../Sources/App/MCP/JSONRPCRequest.swift | 35 +- .../Sources/App/MCP/MCPAgentCopy.swift | 6 +- .../Sources/App/MCP/MCPConstants.swift | 21 +- .../Sources/App/MCP/MCPPaginator.swift | 59 +++ .../Sources/App/MCP/MCPProtocolVersion.swift | 15 - .../Sources/App/MCP/ToolHandlers.swift | 62 ++- .../HardenPortableSkillRuntime.swift | 91 ++++ .../Sources/App/Models/CompiledSkill.swift | 4 + .../App/Models/SkillRuntimeModels.swift | 17 + .../App/Runtime/CompiledSkillDocument.swift | 30 ++ .../App/Runtime/SkillCanonicalCompiler.swift | 195 +++++++- .../Runtime/SkillPackageResourceService.swift | 154 +++++++ .../App/Runtime/SkillRuntimeResolver.swift | 198 ++++++--- .../Runtime/SkillRuntimeToolHandlers.swift | 407 ++++++++++++++--- .../SkillMetadataWritebackService.swift | 112 ++++- .../Sources/App/Sync/Compiler.swift | 105 ++++- .../Sources/App/Sync/Pipeline.swift | 130 +++++- .../Sources/App/Sync/Validator.swift | 37 +- .../mcp-gateway/Sources/App/configure.swift | 3 +- .../AppTests/JSONRPCRequestDecodeTests.swift | 33 +- .../AppTests/MCPAgentVisibilityTests.swift | 94 +++- .../Tests/AppTests/McpToolNamingTests.swift | 284 +++++++++++- .../AppTests/SkillRuntimeHardeningTests.swift | 418 ++++++++++++++++++ 30 files changed, 2817 insertions(+), 393 deletions(-) create mode 100644 services/mcp-gateway/Sources/App/MCP/MCPPaginator.swift delete mode 100644 services/mcp-gateway/Sources/App/MCP/MCPProtocolVersion.swift create mode 100644 services/mcp-gateway/Sources/App/Migrations/HardenPortableSkillRuntime.swift create mode 100644 services/mcp-gateway/Sources/App/Runtime/SkillPackageResourceService.swift create mode 100644 services/mcp-gateway/Tests/AppTests/SkillRuntimeHardeningTests.swift diff --git a/packages/mycontext-api-contract/BACKEND_API_CONTRACT.md b/packages/mycontext-api-contract/BACKEND_API_CONTRACT.md index a6e2329..a3c2062 100644 --- a/packages/mycontext-api-contract/BACKEND_API_CONTRACT.md +++ b/packages/mycontext-api-contract/BACKEND_API_CONTRACT.md @@ -533,18 +533,22 @@ Dashboard-only aggregate of the active release MCP surface plus catalog markdown | GET | `/projects/:id/request-logs` | Yes | List request logs | # Portable Skill Runtime (schema v1) -Projects expose five stable, colon-free MCP tools in addition to `mycontext_catalog` and compiled skill capabilities: +Projects expose exactly three stable, colon-free runtime tools by default: - `resolve_context` bootstraps a task with ordered active and suggested skills, provenance, conflicts, capability bindings, missing context, and a trace. -- `discover_skills` evaluates a new intent or event while retaining current skill IDs. - `get_skill` retrieves one complete compiled skill by stable ID and optional version. -- `list_capabilities` binds abstract requirements against a provider-neutral tool inventory. - `report_skill_feedback` persists version-specific evidence and returns an issue draft. It never reports an external side effect unless the harness performs one. -Because the current shared MCP request dependency accepts string-valued tool arguments, structured `context`, `available_tools`, and `current_skill_ids` inputs are JSON encoded strings. Tool results are versioned JSON text. +The one-release compatibility aliases `mycontext_catalog`, `discover_skills`, and `list_capabilities` remain callable but are omitted from `tools/list`. Their legacy arguments and output wrappers are normalized through the canonical runtime handlers; they do not maintain separate discovery or scoring logic. + +Tool arguments preserve native nested JSON. Canonical results include both `structuredContent` and an equivalent JSON text content item for clients that do not consume structured results. Invalid arguments and unknown tool names return JSON-RPC `-32602`; failures encountered while executing an accepted tool call return a successful MCP tool response with `isError: true`. + +Generated per-skill tools are disabled by default, so the default `tools/list` result is exactly the three canonical tools. A project can temporarily opt into legacy compiled-tool listing and invocation by setting `legacy_compiled_tools_enabled: true` in `provider_preferences_json` through `PATCH /projects/:id/skill-runtime`. Canonical and alias names are reserved and suppress any colliding compiled capability even when this switch is enabled. Runtime frontmatter supports `kind`, `scope`, `activation`, `enforcement`, `priority`, `requires`, `conflictsWith`, `version`, and `lifecycle`. Legacy skills remain retrievable but compile with `explicit` activation and structured clarification questions. +Runtime assignments are exact-target records: `target_type` must equal `scope`, and `target_id` must identify the organization, workspace, repository, or task being matched (`*` or `global` for global scope). Legacy unscoped rows remain database-compatible during rolling deployment but are not activated or returned by the dashboard API. + Dashboard APIs: - `GET|PATCH /projects/:id/skill-runtime` reads or updates scoped assignments, semantic settings, provider preferences, feedback authorization, telemetry consent, and recent trace events. diff --git a/services/mcp-gateway/Package.resolved b/services/mcp-gateway/Package.resolved index 4080c0b..09186b4 100644 --- a/services/mcp-gateway/Package.resolved +++ b/services/mcp-gateway/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "80bd3d121111407217dfe64c38017324fcfd69305f2bd4fa8dc361c61f82c3b2", + "originHash" : "ba4c63013da111e1a2c4c01c58d80d0aa858a44be5446992592add2b59b68a4a", "pins" : [ { "identity" : "async-http-client", @@ -78,8 +78,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Stygian-Tech/mcp-server-kit.git", "state" : { - "branch" : "f6ab939", - "revision" : "f6ab939df5ecefae9ae36a6becb4b9f83f858ff4" + "revision" : "c5c97ed9d7d7ce25f6f05e13883eaa4f61bf786e" } }, { diff --git a/services/mcp-gateway/Package.swift b/services/mcp-gateway/Package.swift index 674a481..1e3aa2a 100644 --- a/services/mcp-gateway/Package.swift +++ b/services/mcp-gateway/Package.swift @@ -15,7 +15,7 @@ let package = Package( .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.0"), .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.13.0"), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), - .package(url: "https://github.com/Stygian-Tech/mcp-server-kit.git", revision: "f6ab939"), + .package(url: "https://github.com/Stygian-Tech/mcp-server-kit.git", revision: "c5c97ed9d7d7ce25f6f05e13883eaa4f61bf786e"), ], targets: [ .executableTarget( diff --git a/services/mcp-gateway/Sources/App/Controllers/MCPController.swift b/services/mcp-gateway/Sources/App/Controllers/MCPController.swift index ce063ee..c29123a 100644 --- a/services/mcp-gateway/Sources/App/Controllers/MCPController.swift +++ b/services/mcp-gateway/Sources/App/Controllers/MCPController.swift @@ -1,4 +1,5 @@ import Fluent +import MCPServerKit import Vapor private struct MCPDispatchOutput { @@ -17,6 +18,10 @@ struct MCPController { guard let projectId = project.id else { return Response(status: .internalServerError, body: .init(string: "Invalid project")) } + if let transportError = validateTransportHeaders(req: req) { + return transportError + } + let requestProtocolVersion = effectiveProtocolVersion(req: req) let clientName: String? = mcpClientLabel(req: req) @@ -47,6 +52,17 @@ struct MCPController { return res } + if let envelopeError = validateJSONRPCEnvelope(body) { + let out = try await serveRpcError( + id: body.id == .null ? nil : body.id, + code: -32600, + message: envelopeError, + req: req + ) + req.attachMcpCatalogRevisionHeader(to: out.response) + return out.response + } + let out: MCPDispatchOutput switch body.method { case "initialize": @@ -54,13 +70,25 @@ struct MCPController { out = try await handleInitialize(req: req, project: project, params: body.params, id: body.id) case "tools/list": req.logger.mcpTrace("mcp dispatch handler=tools/list projectId=\(projectId.uuidString)") - out = try await handleToolsList(req: req, project: project, id: body.id) + out = try await handleToolsList( + req: req, + project: project, + params: body.params, + id: body.id, + protocolVersion: requestProtocolVersion + ) case "tools/call": req.logger.mcpTrace("mcp dispatch handler=tools/call projectId=\(projectId.uuidString)") - out = try await handleToolsCall(req: req, projectId: projectId, params: body.params, id: body.id) + out = try await handleToolsCall( + req: req, + projectId: projectId, + params: body.params, + id: body.id, + protocolVersion: requestProtocolVersion + ) case "resources/list": req.logger.mcpTrace("mcp dispatch handler=resources/list projectId=\(projectId.uuidString)") - out = try await handleResourcesList(req: req, project: project, id: body.id) + out = try await handleResourcesList(req: req, project: project, params: body.params, id: body.id) case "resources/read": req.logger.mcpTrace("mcp dispatch handler=resources/read projectId=\(projectId.uuidString)") out = try await handleResourcesRead(req: req, project: project, params: body.params, id: body.id) @@ -72,7 +100,7 @@ struct MCPController { out = try await handleResourcesUnsubscribe(req: req, params: body.params, id: body.id) case "prompts/list": req.logger.mcpTrace("mcp dispatch handler=prompts/list projectId=\(projectId.uuidString)") - out = try await handlePromptsList(req: req, project: project, id: body.id) + out = try await handlePromptsList(req: req, project: project, params: body.params, id: body.id) case "prompts/get": req.logger.mcpTrace("mcp dispatch handler=prompts/get projectId=\(projectId.uuidString)") out = try await handlePromptsGet(req: req, project: project, params: body.params, id: body.id) @@ -111,6 +139,64 @@ struct MCPController { return out.response } + static func validateTransportHeaders(req: Request) -> Response? { + if let rawVersion = req.headers.first(name: "MCP-Protocol-Version") { + let version = rawVersion.trimmingCharacters(in: .whitespacesAndNewlines) + guard MCPServerKit.MCPProtocolVersion(rawValue: version) != nil else { + return Response(status: .badRequest, body: .init(string: "Unsupported MCP-Protocol-Version")) + } + } + if let accept = req.headers.first(name: .accept), accept != "*/*" { + let normalized = accept.lowercased() + let valid = req.method == .GET + ? normalized.contains("text/event-stream") + : normalized.contains("application/json") && normalized.contains("text/event-stream") + guard valid else { + let expected = req.method == .GET ? "text/event-stream" : "application/json and text/event-stream" + return Response(status: .badRequest, body: .init(string: "Accept must include \(expected)")) + } + } + if let rawOrigin = req.headers.first(name: .origin) { + let origin = rawOrigin.trimmingCharacters(in: .whitespacesAndNewlines) + let configured = (Environment.get("MCP_ALLOWED_ORIGINS") ?? "") + .split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let allowed = Set(AppFrontendURL.allowedOriginBases() + configured) + guard allowed.contains(where: { $0.caseInsensitiveCompare(origin) == .orderedSame }) else { + return Response(status: .forbidden, body: .init(string: "Invalid origin")) + } + } + return nil + } + + static func effectiveProtocolVersion(req: Request) -> MCPServerKit.MCPProtocolVersion { + guard let raw = req.headers.first(name: "MCP-Protocol-Version")? + .trimmingCharacters(in: .whitespacesAndNewlines), + let version = MCPServerKit.MCPProtocolVersion(rawValue: raw) else { + return .missingHTTPHeaderFallback + } + return version + } + + private static func supportsRichToolResults(_ version: MCPServerKit.MCPProtocolVersion) -> Bool { + switch version { + case .v2025_06_18, .v2025_11_25: return true + case .v2024_11_05, .v2025_03_26: return false + } + } + + private static func validateJSONRPCEnvelope(_ body: JSONRPCRequest) -> String? { + guard body.jsonrpc == "2.0" else { return "Invalid Request: jsonrpc must be 2.0" } + let isNotification = body.method == .notificationsInitialized || body.method == .notificationsCancelled + if isNotification { + guard body.id == nil else { return "Invalid Request: notifications must not include an id" } + } else { + guard let id = body.id, id != .null else { + return "Invalid Request: requests must include a non-null id" + } + } + return nil + } + private static func serveSuccess(_ content: some Content, req: Request) async throws -> MCPDispatchOutput { let response = try await content.encodeResponse(for: req) return MCPDispatchOutput( @@ -122,7 +208,7 @@ struct MCPController { } private static func serveNotificationAck() -> MCPDispatchOutput { - let response = Response(status: .noContent) + let response = Response(status: .accepted) return MCPDispatchOutput( response: response, httpStatus: Int(response.status.code), @@ -183,7 +269,7 @@ struct MCPController { guard let projectId = project.id else { return try await serveRpcError(id: id, code: -32603, message: "Invalid project", req: req) } - let negotiated = MCPProtocolVersion.negotiated(requested: params?.protocolVersion) + let negotiated = MCPServerKit.MCPProtocolVersion.negotiated(requested: params?.protocolVersion) let dash = projectDashboardURL(projectId: projectId) let instructions = MCPAgentCopy.initializeInstructions(projectName: project.name, projectDashboardURL: dash) let result = InitializeResult( @@ -210,83 +296,96 @@ struct MCPController { return "\(base)/projects/\(projectId.uuidString)" } - private static func syntheticCatalogTool() -> MCPTool { - let schemaJson = CapabilitySchemaBuilder.catalogToolInputSchemaJson() - return MCPTool( - name: MCPConstants.catalogToolName, - description: "Primary skill discovery and routing tool. Call first with the current task before choosing project-specific skills; use mode=route to rank skills or mode=skill to load a full SKILL.md body.", - inputSchema: InputSchema.fromCapabilitySchemaJson(schemaJson) - ) - } - - private static func runtimeTools() -> [MCPTool] { + private static func runtimeTools(protocolVersion: MCPServerKit.MCPProtocolVersion) -> [MCPTool] { let descriptions = [ "resolve_context": "Task bootstrap: returns ordered active and suggested skills, conflicts, provenance, capability bindings, and a resolution trace.", - "discover_skills": "Mid-task discovery for a new intent or event while preserving currently active skills.", "get_skill": "Returns the complete versioned compiled skill document, including original Markdown and provenance.", - "list_capabilities": "Resolves abstract skill requirements against a provider-neutral JSON tool inventory.", "report_skill_feedback": "Stores version-specific skill feedback and returns an issue draft; never implies an external side effect occurred." ] + let titles = [ + "resolve_context": "Resolve project context", + "get_skill": "Get complete skill", + "report_skill_feedback": "Report skill feedback", + ] return MCPConstants.runtimeToolNames.map { name in - MCPTool( + let isReadOnly = name != MCPConstants.reportSkillFeedbackToolName + if supportsRichToolResults(protocolVersion) { + return MCPTool( + name: name, + description: descriptions[name], + inputSchema: CapabilitySchemaBuilder.runtimeToolInputSchema(name: name), + title: titles[name], + outputSchema: CapabilitySchemaBuilder.runtimeToolOutputSchema(name: name), + annotations: MCPToolAnnotations( + title: titles[name], + readOnlyHint: isReadOnly, + destructiveHint: false, + idempotentHint: isReadOnly, + openWorldHint: false + ) + ) + } + return MCPTool( name: name, description: descriptions[name], - inputSchema: InputSchema.fromCapabilitySchemaJson(CapabilitySchemaBuilder.runtimeToolInputSchemaJson(name: name)) + inputSchema: CapabilitySchemaBuilder.runtimeToolInputSchema(name: name) ) } } - private static func handleToolsList(req: Request, project: Project, id: JSONRPCId?) async throws -> MCPDispatchOutput { + private static func handleToolsList( + req: Request, + project: Project, + params: JSONRPCParams?, + id: JSONRPCId?, + protocolVersion: MCPServerKit.MCPProtocolVersion + ) async throws -> MCPDispatchOutput { struct ToolsListPayload: Content { let jsonrpc: String let id: JSONRPCId? let result: ToolsListResult } - let syntheticTools = [syntheticCatalogTool()] + runtimeTools() - - // activeReleaseId is already populated from storage — no DB round-trip needed. - guard let releaseId = project.activeReleaseId else { - req.logger.mcpTrace("mcp tools/list result=catalog_only reason=no_active_release") - return try await serveSuccess( - ToolsListPayload(jsonrpc: "2.0", id: id, result: ToolsListResult(tools: syntheticTools)), - req: req - ) - } - - let compiledSkillIds = try await MCPCatalogService.readyCompiledSkillIds(releaseId: releaseId, db: req.db) - guard !compiledSkillIds.isEmpty else { - req.logger.mcpTrace("mcp tools/list result=catalog_only reason=no_ready_skills releaseId=\(releaseId.uuidString)") - return try await serveSuccess( - ToolsListPayload(jsonrpc: "2.0", id: id, result: ToolsListResult(tools: syntheticTools)), - req: req - ) - } - - let capabilityDefs = try await MCPCatalogService.capabilityDefs( - compiledSkillIds: compiledSkillIds, - types: ["tool"], - db: req.db - ) - req.logger.mcpTrace("mcp tools/list readySkillRows=\(compiledSkillIds.count) toolCaps=\(capabilityDefs.count)") - - let rest = capabilityDefs.map { cap in - let compiled = cap.compiledSkill - let hints = McpCatalogMarkdown.routingHints(for: compiled) - let desc = MCPAgentCopy.toolDescription(baseSummary: compiled.summary, hints: hints) - let inputSchema = InputSchema.fromCapabilitySchemaJson(cap.schemaJson) - return MCPTool( - name: cap.capabilityName, - description: desc, - inputSchema: inputSchema + var tools = runtimeTools(protocolVersion: protocolVersion) + let legacyEnabled = try await ToolHandlers.legacyCompiledToolsEnabled(db: req.db, projectId: project.id!) + if legacyEnabled, let releaseId = project.activeReleaseId { + let compiledSkillIds = try await MCPCatalogService.readyCompiledSkillIds(releaseId: releaseId, db: req.db) + let capabilityDefs = try await MCPCatalogService.capabilityDefs( + compiledSkillIds: compiledSkillIds, + types: ["tool"], + db: req.db ) - } - - let listResult = ToolsListResult(tools: syntheticTools + rest) + let legacyTools = capabilityDefs.compactMap { cap -> MCPTool? in + guard !MCPConstants.isReservedRuntimeToolName(cap.capabilityName) else { + req.logger.warning("mcp tools/list suppressed compiled capability with reserved runtime name=\(cap.capabilityName)") + return nil + } + let compiled = cap.compiledSkill + let hints = McpCatalogMarkdown.routingHints(for: compiled) + return MCPTool( + name: cap.capabilityName, + description: MCPAgentCopy.toolDescription(baseSummary: compiled.summary, hints: hints), + inputSchema: InputSchema.fromCapabilitySchemaJson(cap.schemaJson) + ) + }.sorted { $0.name < $1.name } + tools.append(contentsOf: legacyTools) + } + let scope = "tools:\(project.id!.uuidString):\(project.activeReleaseId?.uuidString ?? "none"):\(legacyEnabled)" + let page: MCPPaginationPage + do { page = try MCPPaginator.page(tools, cursor: params?.cursor, scope: scope) } + catch { return try await serveRpcError(id: id, code: -32602, message: "Invalid pagination cursor", req: req) } + req.logger.mcpTrace("mcp tools/list count=\(page.items.count) legacyCompiledTools=\(legacyEnabled)") + let listResult = ToolsListResult(tools: page.items, nextCursor: page.nextCursor) return try await serveSuccess(ToolsListPayload(jsonrpc: "2.0", id: id, result: listResult), req: req) } - private static func handleToolsCall(req: Request, projectId: UUID, params: JSONRPCParams?, id: JSONRPCId?) async throws -> MCPDispatchOutput { + private static func handleToolsCall( + req: Request, + projectId: UUID, + params: JSONRPCParams?, + id: JSONRPCId?, + protocolVersion: MCPServerKit.MCPProtocolVersion + ) async throws -> MCPDispatchOutput { struct ToolCallPayload: Content { let jsonrpc: String let id: JSONRPCId? @@ -296,15 +395,47 @@ struct MCPController { guard let name = params?.name else { return try await serveRpcError(id: id, code: -32602, message: "Invalid params: missing name", req: req) } - let argKeys = (params?.arguments ?? [:]).keys.sorted().joined(separator: ",") + let arguments = params?.arguments ?? [:] + let argKeys = arguments.keys.sorted().joined(separator: ",") req.logger.mcpTrace("mcp tools/call tool=\(name) argKeys=[\(argKeys)]") do { - let content = try await ToolHandlers.handle(name: name, arguments: params?.arguments ?? [:], db: req.db, projectId: projectId) - let toolResult = ToolCallResult(content: [ContentItem(type: "text", text: content)], isError: false) + let output = try await ToolHandlers.handle( + name: name, + arguments: arguments, + db: req.db, + projectId: projectId + ) + let richResults = supportsRichToolResults(protocolVersion) + let content = richResults ? output.content : output.content.filter { + if case .text = $0 { return true } + return false + } + let toolResult = ToolCallResult( + content: content, + structuredContent: richResults ? output.structuredContent : nil, + isError: false + ) return try await serveSuccess(ToolCallPayload(jsonrpc: "2.0", id: id, result: toolResult), req: req) } catch let ToolHandlerError.unknownTool(name: unknown) { - return try await serveRpcError(id: id, code: -32601, message: "Unknown tool: \(unknown)", req: req) + return try await serveRpcError(id: id, code: -32602, message: "Unknown tool: \(unknown)", req: req) + } catch let abort as Abort { + if abort.status == .badRequest { + return try await serveRpcError(id: id, code: -32602, message: abort.reason, req: req) + } + let error = JSONValue.object([ + "error": .object([ + "code": .string("tool_failure"), + "message": .string(abort.reason), + ]), + ]) + let richResults = supportsRichToolResults(protocolVersion) + let toolResult = ToolCallResult( + text: SkillRuntimeJSON.encode(error), + structuredContent: richResults ? error : nil, + isError: true + ) + return try await serveSuccess(ToolCallPayload(jsonrpc: "2.0", id: id, result: toolResult), req: req) } catch { let message: String if AppEnvironment.deployKind() == .prod { @@ -313,17 +444,32 @@ struct MCPController { } else { message = error.localizedDescription } - return try await serveRpcError(id: id, code: -32603, message: message, req: req) + let failure = JSONValue.object([ + "error": .object([ + "code": .string("tool_failure"), + "message": .string(message), + ]), + ]) + let richResults = supportsRichToolResults(protocolVersion) + let toolResult = ToolCallResult( + text: SkillRuntimeJSON.encode(failure), + structuredContent: richResults ? failure : nil, + isError: true + ) + return try await serveSuccess(ToolCallPayload(jsonrpc: "2.0", id: id, result: toolResult), req: req) } } - private static func handleResourcesList(req: Request, project: Project, id: JSONRPCId?) async throws -> MCPDispatchOutput { + private static func handleResourcesList(req: Request, project: Project, params: JSONRPCParams?, id: JSONRPCId?) async throws -> MCPDispatchOutput { struct Payload: Content { let jsonrpc: String let id: JSONRPCId? let result: ResourcesListResult } guard let releaseId = project.activeReleaseId else { + if params?.cursor != nil { + return try await serveRpcError(id: id, code: -32602, message: "Invalid pagination cursor", req: req) + } return try await serveSuccess( Payload(jsonrpc: "2.0", id: id, result: ResourcesListResult(resources: [], nextCursor: nil)), req: req @@ -348,10 +494,20 @@ struct MCPController { failureModes: meta.failureModes, invokeFirst: meta.invokeFirst ) + }.sorted { $0.uri < $1.uri } + let page: MCPPaginationPage + do { + page = try MCPPaginator.page( + resources, + cursor: params?.cursor, + scope: "resources:\(project.id!.uuidString):\(releaseId.uuidString)" + ) + } catch { + return try await serveRpcError(id: id, code: -32602, message: "Invalid pagination cursor", req: req) } - req.logger.mcpTrace("mcp resources/list count=\(resources.count)") + req.logger.mcpTrace("mcp resources/list count=\(page.items.count)") return try await serveSuccess( - Payload(jsonrpc: "2.0", id: id, result: ResourcesListResult(resources: resources, nextCursor: nil)), + Payload(jsonrpc: "2.0", id: id, result: ResourcesListResult(resources: page.items, nextCursor: page.nextCursor)), req: req ) } @@ -367,6 +523,30 @@ struct MCPController { } let uriLog = uri.count > 120 ? String(uri.prefix(120)) + "…" : uri req.logger.mcpTrace("mcp resources/read uri=\(uriLog)") + do { + if let reference = try SkillPackageResourceService.parse(uri: uri) { + let (compiled, document) = try await SkillPackageResourceService.activeCompiledSkill( + projectId: project.id!, + skillId: reference.skillId, + version: reference.version, + db: req.db + ) + if let path = reference.path { + let file = try await SkillPackageResourceService.file(compiled: compiled, path: path, db: req.db) + let mimeType = file.contentType ?? "application/octet-stream" + let contents = if let text = String(data: file.content, encoding: .utf8) { + ResourceContents(uri: uri, mimeType: mimeType, text: text) + } else { + ResourceContents(uri: uri, mimeType: mimeType, blob: file.content.base64EncodedString()) + } + return try await serveSuccess(Payload(jsonrpc: "2.0", id: id, result: .init(contents: [contents])), req: req) + } + let contents = ResourceContents(uri: uri, mimeType: "text/markdown", text: document.instructions) + return try await serveSuccess(Payload(jsonrpc: "2.0", id: id, result: .init(contents: [contents])), req: req) + } + } catch let abort as Abort { + return try await serveRpcError(id: id, code: -32602, message: abort.reason, req: req) + } guard let releaseId = project.activeReleaseId else { return try await serveRpcError(id: id, code: -32602, message: "No active release", req: req) } @@ -435,15 +615,18 @@ struct MCPController { ) } - private static func handlePromptsList(req: Request, project: Project, id: JSONRPCId?) async throws -> MCPDispatchOutput { + private static func handlePromptsList(req: Request, project: Project, params: JSONRPCParams?, id: JSONRPCId?) async throws -> MCPDispatchOutput { struct Payload: Content { let jsonrpc: String let id: JSONRPCId? let result: PromptsListResult } guard let releaseId = project.activeReleaseId else { + if params?.cursor != nil { + return try await serveRpcError(id: id, code: -32602, message: "Invalid pagination cursor", req: req) + } return try await serveSuccess( - Payload(jsonrpc: "2.0", id: id, result: PromptsListResult(prompts: [])), + Payload(jsonrpc: "2.0", id: id, result: PromptsListResult(prompts: [], nextCursor: nil)), req: req ) } @@ -462,9 +645,19 @@ struct MCPController { description: desc, arguments: nil ) + }.sorted { $0.name < $1.name } + let page: MCPPaginationPage + do { + page = try MCPPaginator.page( + prompts, + cursor: params?.cursor, + scope: "prompts:\(project.id!.uuidString):\(releaseId.uuidString)" + ) + } catch { + return try await serveRpcError(id: id, code: -32602, message: "Invalid pagination cursor", req: req) } return try await serveSuccess( - Payload(jsonrpc: "2.0", id: id, result: PromptsListResult(prompts: prompts)), + Payload(jsonrpc: "2.0", id: id, result: PromptsListResult(prompts: page.items, nextCursor: page.nextCursor)), req: req ) } @@ -497,7 +690,9 @@ struct MCPController { let compiled = cap.compiledSkill var text = compiled.skillBody ?? compiled.summary ?? "" if let args = params?.arguments, !args.isEmpty { - let lines = args.map { "\($0.key): \($0.value)" }.joined(separator: "\n") + let lines = args.keys.sorted().map { key in + "\(key): \(SkillRuntimeJSON.encode(args[key]!))" + }.joined(separator: "\n") text = "Context:\n\(lines)\n\n\(text)" } let result = PromptGetResult( diff --git a/services/mcp-gateway/Sources/App/Controllers/McpSseController.swift b/services/mcp-gateway/Sources/App/Controllers/McpSseController.swift index 76a2ce6..6e61783 100644 --- a/services/mcp-gateway/Sources/App/Controllers/McpSseController.swift +++ b/services/mcp-gateway/Sources/App/Controllers/McpSseController.swift @@ -7,6 +7,9 @@ enum McpSseController { guard let project = req.storage[ProjectKey.self], let pid = project.id else { throw Abort(.unauthorized) } + if let transportError = MCPController.validateTransportHeaders(req: req) { + return transportError + } let app = req.application let subId = UUID() let stream = AsyncStream { continuation in diff --git a/services/mcp-gateway/Sources/App/Controllers/ProjectController.swift b/services/mcp-gateway/Sources/App/Controllers/ProjectController.swift index 350cca3..48915b5 100644 --- a/services/mcp-gateway/Sources/App/Controllers/ProjectController.swift +++ b/services/mcp-gateway/Sources/App/Controllers/ProjectController.swift @@ -960,17 +960,29 @@ struct ProjectController { static func updateCompiledSkill(req: Request) async throws -> CompiledSkillResponse { let account = try requireAccount(req) let project = try await requireProject(req, accountId: account.id!) + let response = try await req.db.transaction { transaction in + try await updateCompiledSkill(req: req, project: project, db: transaction) + } + req.application.mcpCatalogNotifications.bumpCatalog(for: project.id!) + return response + } + + private static func updateCompiledSkill( + req: Request, + project: Project, + db: Database + ) async throws -> CompiledSkillResponse { guard let releaseId = req.parameters.get("releaseId", as: UUID.self), let compiledSkillId = req.parameters.get("compiledSkillId", as: UUID.self) else { throw Abort(.badRequest, reason: "Invalid release or compiled skill ID") } - guard let release = try await Release.query(on: req.db) + guard let release = try await Release.query(on: db) .filter(\.$id == releaseId) .filter(\.$project.$id == project.id!) .first() else { throw Abort(.notFound, reason: "Release not found") } - guard let compiled = try await CompiledSkill.query(on: req.db) + guard let compiled = try await CompiledSkill.query(on: db) .filter(\.$id == compiledSkillId) .filter(\.$release.$id == releaseId) .first() else { @@ -1040,29 +1052,38 @@ struct ProjectController { compiled.clarificationRequired = document.validation.clarificationRequired compiled.clarificationJson = SkillRuntimeJSON.encode(SkillCanonicalCompiler.questionsForRuntime(fields: remaining.sorted())) - let existingOverride = try await SkillRuntimeOverride.query(on: req.db) - .filter(\.$project.$id == project.id!).filter(\.$skillId == document.id).filter(\.$scope == document.scope.rawValue).first() + let priorOverrides = try await SkillRuntimeOverride.query(on: db) + .filter(\.$project.$id == project.id!) + .filter(\.$skillId == document.id) + .all() + let existingOverride = priorOverrides.first { $0.scope == document.scope.rawValue } + for stale in priorOverrides where stale.id != existingOverride?.id { + try await stale.delete(on: db) + } let overrideRow = existingOverride ?? SkillRuntimeOverride() if existingOverride == nil { overrideRow.$project.id = project.id!; overrideRow.skillId = document.id; overrideRow.scope = document.scope.rawValue } - overrideRow.metadataJson = SkillRuntimeJSON.encode(runtime); overrideRow.sourceChecksum = document.source.checksum - try await overrideRow.save(on: req.db) + overrideRow.metadataJson = SkillRuntimeJSON.encode(runtime) + overrideRow.sourceChecksum = document.source.checksum + overrideRow.baseChecksum = document.source.checksum + overrideRow.isStale = false + try await overrideRow.save(on: db) } - try await compiled.save(on: req.db) + try await compiled.save(on: db) if bodyTextChanged, hadBodyDiff { release.skillBodyChangesCount = max(0, release.skillBodyChangesCount - 1) - try await release.save(on: req.db) + try await release.save(on: db) } if let routing = body.routing { - try await Self.applyRoutingPatch(compiledSkillId: compiled.id!, patch: routing, db: req.db) + try await Self.applyRoutingPatch(compiledSkillId: compiled.id!, patch: routing, db: db) } let exposureChanged = compiled.exposureType != exposureBefore let summaryChanged = compiled.summary != summaryBefore let routingChanged = body.routing != nil - let routingRule = try await RoutingRule.query(on: req.db) + let routingRule = try await RoutingRule.query(on: db) .filter(\.$compiledSkill.$id == compiled.id!) .first() let routingHints = RoutingHints.from(rule: routingRule) - let caps = try await CapabilityDef.query(on: req.db) + let caps = try await CapabilityDef.query(on: db) .filter(\.$compiledSkill.$id == compiled.id!) .all() let capType = compiled.exposureType == "guidance" ? "prompt" : compiled.exposureType @@ -1086,7 +1107,7 @@ struct ProjectController { for cap in caps { cap.type = capType cap.schemaJson = newSchema - try await cap.save(on: req.db) + try await cap.save(on: db) } let routingHintsAfter = RoutingHints.from(rule: routingRule) @@ -1120,11 +1141,8 @@ struct ProjectController { compiled.status = autoStatus } } - try await compiled.save(on: req.db) + try await compiled.save(on: db) - if releaseId == project.activeReleaseId, let pid = project.id { - req.application.mcpCatalogNotifications.bumpCatalog(for: pid) - } return Self.compiledSkillResponse(compiled, schemaJson: newSchema, routingRule: routingRule) } @@ -1162,7 +1180,15 @@ struct ProjectController { static func updateRuntimeSettings(req: Request) async throws -> RuntimeSettingsResponse { let account = try requireAccount(req) let project = try await requireProject(req, accountId: account.id!) - struct AssignmentPatch: Content { let skill_id: String; let scope: String; let activation_mode: String; let required: Bool; let priority: Int } + struct AssignmentPatch: Content { + let skill_id: String + let scope: String + let activation_mode: String + let required: Bool + let priority: Int + let target_type: String? + let target_id: String? + } struct Body: Content { let telemetry_enabled: Bool? let telemetry_retention_days: Int? @@ -1174,25 +1200,78 @@ struct ProjectController { let assignments: [AssignmentPatch]? } let body = try req.content.decode(Body.self) + let normalizedAssignments: [(skillId: String, scope: String, activation: String, required: Bool, priority: Int, targetType: String, targetId: String)]? + if let patches = body.assignments { + let activeSkillIds: Set + if let releaseId = project.activeReleaseId { + activeSkillIds = Set(try await CompiledSkill.query(on: req.db) + .filter(\.$release.$id == releaseId) + .filter(\.$status == "ready") + .all() + .compactMap { $0.skillId ?? $0.name }) + } else { + activeSkillIds = [] + } + var uniqueness = Set() + normalizedAssignments = try patches.map { patch in + let skillId = patch.skill_id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !skillId.isEmpty, skillId.count <= 128, activeSkillIds.contains(skillId) else { + throw Abort(.badRequest, reason: "Assignments must reference a ready skill in the active release") + } + guard SkillScope(rawValue: patch.scope) != nil, + SkillActivationMode(rawValue: patch.activation_mode) != nil else { + throw Abort(.badRequest, reason: "Invalid assignment scope or activation mode") + } + guard (0...100).contains(patch.priority) else { + throw Abort(.badRequest, reason: "Assignment priority must be between 0 and 100") + } + guard let targetType = patch.target_type?.trimmingCharacters(in: .whitespacesAndNewlines), + let targetId = patch.target_id?.trimmingCharacters(in: .whitespacesAndNewlines), + !targetType.isEmpty, !targetId.isEmpty, targetId.count <= 512, + targetType == patch.scope else { + throw Abort(.badRequest, reason: "Assignment target type must match its scope and include an exact target identity") + } + if targetType == SkillScope.global.rawValue, targetId != "*", targetId != "global" { + throw Abort(.badRequest, reason: "Global assignments must target `*` or `global`") + } + let identity = "\(skillId)\u{1f}\(patch.scope)\u{1f}\(targetType)\u{1f}\(targetId)" + guard uniqueness.insert(identity).inserted else { + throw Abort(.badRequest, reason: "Duplicate assignment for skill, scope, and target identity") + } + return (skillId, patch.scope, patch.activation_mode, patch.required, patch.priority, targetType, targetId) + } + } else { + normalizedAssignments = nil + } let settings = try await runtimeSettingsRow(projectId: project.id!, db: req.db) if let value = body.telemetry_enabled { settings.telemetryEnabled = value } if let value = body.telemetry_retention_days { settings.telemetryRetentionDays = min(365, max(1, value)) } - if let value = body.semantic_enabled { settings.semanticEnabled = value } - if body.embedding_provider != nil { settings.embeddingProvider = body.embedding_provider?.trimmingCharacters(in: .whitespacesAndNewlines) } - if body.embedding_model != nil { settings.embeddingModel = body.embedding_model?.trimmingCharacters(in: .whitespacesAndNewlines) } + // Embedding controls are retained in the wire response for compatibility, but resolution + // is deterministic and never reads or writes embeddings. + settings.semanticEnabled = false + settings.embeddingProvider = nil + settings.embeddingModel = nil if let value = body.feedback_issue_creation_enabled { settings.feedbackIssueCreationEnabled = value } if let json = body.provider_preferences_json { guard json.isEmpty || (try? JSONSerialization.jsonObject(with: Data(json.utf8))) != nil else { throw Abort(.badRequest, reason: "provider_preferences_json must be valid JSON") } settings.providerPreferencesJson = json.isEmpty ? nil : json } - try await settings.save(on: req.db) - if let patches = body.assignments { - try await SkillAssignment.query(on: req.db).filter(\.$project.$id == project.id!).delete() - for patch in patches { - guard SkillScope(rawValue: patch.scope) != nil, SkillActivationMode(rawValue: patch.activation_mode) != nil else { throw Abort(.badRequest, reason: "Invalid assignment scope or activation mode") } - let row = SkillAssignment(); row.$project.id = project.id!; row.skillId = patch.skill_id - row.scope = patch.scope; row.activationMode = patch.activation_mode; row.required = patch.required - row.priority = min(100, max(0, patch.priority)); try await row.save(on: req.db) + try await req.db.transaction { transaction in + try await settings.save(on: transaction) + if let patches = normalizedAssignments { + try await SkillAssignment.query(on: transaction).filter(\.$project.$id == project.id!).delete() + for patch in patches { + let row = SkillAssignment() + row.$project.id = project.id! + row.skillId = patch.skillId + row.scope = patch.scope + row.activationMode = patch.activation + row.required = patch.required + row.priority = patch.priority + row.targetType = patch.targetType + row.targetId = patch.targetId + try await row.save(on: transaction) + } } } let assignments = try await SkillAssignment.query(on: req.db).filter(\.$project.$id == project.id!).sort(\.$priority, .descending).all() @@ -1205,15 +1284,17 @@ struct ProjectController { if let existing = try await ProjectRuntimeSettings.query(on: db).filter(\.$project.$id == projectId).first() { return existing } let settings = ProjectRuntimeSettings(); settings.$project.id = projectId settings.telemetryEnabled = false; settings.telemetryRetentionDays = 30; settings.semanticEnabled = false + settings.embeddingProvider = nil; settings.embeddingModel = nil settings.feedbackIssueCreationEnabled = false try await settings.save(on: db); return settings } private static func runtimeSettingsResponse(_ settings: ProjectRuntimeSettings, assignments: [SkillAssignment], events: [SkillRuntimeEvent]) -> RuntimeSettingsResponse { - .init(telemetry_enabled: settings.telemetryEnabled, telemetry_retention_days: settings.telemetryRetentionDays, - semantic_enabled: settings.semanticEnabled, embedding_provider: settings.embeddingProvider, - embedding_model: settings.embeddingModel, feedback_issue_creation_enabled: settings.feedbackIssueCreationEnabled, - provider_preferences_json: settings.providerPreferencesJson, assignments: assignments, recent_events: events) + let scopedAssignments = assignments.filter { $0.targetType == $0.scope } + return .init(telemetry_enabled: settings.telemetryEnabled, telemetry_retention_days: settings.telemetryRetentionDays, + semantic_enabled: false, embedding_provider: nil, + embedding_model: nil, feedback_issue_creation_enabled: settings.feedbackIssueCreationEnabled, + provider_preferences_json: settings.providerPreferencesJson, assignments: scopedAssignments, recent_events: events) } private static func apiKeyResponse(_ k: ApiKey, projectId: UUID) -> ApiKeyResponse { diff --git a/services/mcp-gateway/Sources/App/MCP/CapabilitySchemaBuilder.swift b/services/mcp-gateway/Sources/App/MCP/CapabilitySchemaBuilder.swift index e211e98..409f34c 100644 --- a/services/mcp-gateway/Sources/App/MCP/CapabilitySchemaBuilder.swift +++ b/services/mcp-gateway/Sources/App/MCP/CapabilitySchemaBuilder.swift @@ -77,33 +77,175 @@ enum CapabilitySchemaBuilder { } static func runtimeToolInputSchemaJson(name: String) -> String { - let definitions: [String: [(String, String)]] = [ - "resolve_context": [ - ("request", "Current user request or task."), ("user", "Optional user identifier."), - ("organization", "Optional organization name."), ("workspace", "Optional workspace name."), - ("repository", "Optional owner/repository identifier."), - ("available_tools", "Optional JSON array of provider-neutral tool inventory objects.") + (try? encoderString(runtimeToolInputSchema(name: name))) ?? #"{"type":"object","properties":{}}"# + } + + static func runtimeToolInputSchema(name: String) -> InputSchema { + switch name { + case MCPConstants.resolveContextToolName: + return InputSchema( + type: "object", + properties: [ + "request": stringSchema("Current user request or task."), + "event": stringSchema("Optional canonical or freeform runtime event."), + "context": runtimeContextSchema(), + "user": stringSchema("Optional user identifier."), + "organization": stringSchema("Optional organization name."), + "workspace": stringSchema("Optional workspace name."), + "repository": stringSchema("Optional owner/repository identifier."), + "current_skill_ids": InputSchema( + type: "array", + description: "Stable IDs for skills already active in the agent session.", + items: InputSchema(type: "string", minLength: 1), + uniqueItems: true + ), + "available_tools": InputSchema( + type: "array", + description: "Provider-neutral inventory of tools currently available to the agent.", + items: runtimeToolInventoryItemSchema() + ), + ], + required: ["request"], + additionalProperties: false + ) + case MCPConstants.getSkillToolName: + return InputSchema( + type: "object", + properties: [ + "skill_id": stringSchema("Stable skill ID.", minLength: 1), + "version": stringSchema("Optional exact semantic version.", minLength: 1), + "path": stringSchema("Optional safe package-relative file path.", minLength: 1, maxLength: 1_024), + ], + required: ["skill_id"], + additionalProperties: false + ) + case MCPConstants.reportSkillFeedbackToolName: + return InputSchema( + type: "object", + properties: [ + "skill_id": stringSchema("Stable skill ID.", minLength: 1), + "version": stringSchema("Observed skill version.", minLength: 1), + "category": InputSchema( + type: "string", + description: "Feedback category.", + enumValues: [ + "missing_guidance", "ambiguous_instruction", "incorrect_instruction", "conflict", + "missing_capability", "poor_discovery", "outdated_content", "other", + ].map(JSONValue.string) + ), + "summary": stringSchema("Concise problem summary.", minLength: 1, maxLength: 2_000), + "evidence": stringSchema("Reproducible evidence for the observed skill version.", minLength: 1, maxLength: 8_000), + "suggested_change": stringSchema("Optional suggested improvement.", maxLength: 8_000), + "create_issue": InputSchema( + type: "boolean", + description: "Request authorized external issue creation. The server still returns a draft for the harness to execute." + ), + ], + required: ["skill_id", "version", "category", "summary", "evidence"], + additionalProperties: false + ) + default: + return InputSchema(type: "object", properties: [:], additionalProperties: false) + } + } + + static func runtimeToolOutputSchema(name: String) -> InputSchema { + switch name { + case MCPConstants.resolveContextToolName: + return InputSchema( + type: "object", + properties: [ + "schemaVersion": InputSchema(type: "integer", minimum: 1), + "traceId": InputSchema(type: "string", format: "uuid"), + "activeSkills": arrayOfObjectsSchema(), + "suggestedTaskSkills": arrayOfObjectsSchema(), + "capabilityBindings": arrayOfObjectsSchema(), + "missingRequirements": InputSchema(type: "array", items: InputSchema(type: "string")), + "conflicts": arrayOfObjectsSchema(), + "missingContext": InputSchema(type: "array", items: InputSchema(type: "string")), + "eventCanonical": InputSchema(type: "boolean"), + "nextActions": arrayOfObjectsSchema(), + "resolutionTrace": arrayOfObjectsSchema(), + ], + required: [ + "schemaVersion", "traceId", "activeSkills", "suggestedTaskSkills", "capabilityBindings", + "missingRequirements", "conflicts", "missingContext", "nextActions", "resolutionTrace", + ], + additionalProperties: false + ) + case MCPConstants.getSkillToolName: + return InputSchema( + type: "object", + properties: [ + "schemaVersion": InputSchema(type: "integer", minimum: 1), + "kind": InputSchema(type: "string", enumValues: [.string("skill"), .string("file")]), + "id": stringSchema("Stable skill ID."), + "version": stringSchema("Exact skill version."), + "checksum": stringSchema("SHA-256 content checksum."), + "mediaType": stringSchema("Resource media type."), + "resourceUri": stringSchema("Stable ctx resource URI."), + "source": InputSchema(type: "object", additionalProperties: true), + ], + required: ["schemaVersion", "kind", "id", "version", "checksum", "mediaType", "resourceUri", "source"], + additionalProperties: true + ) + case MCPConstants.reportSkillFeedbackToolName: + return InputSchema( + type: "object", + properties: [ + "schemaVersion": InputSchema(type: "integer", minimum: 1), + "feedbackId": InputSchema(type: "string", format: "uuid"), + "effectStatus": InputSchema(type: "string", enumValues: [.string("draft")]), + "issueDraft": InputSchema(type: "object", additionalProperties: true), + "creationAuthorized": InputSchema(type: "boolean"), + "message": InputSchema(type: "string"), + ], + required: ["schemaVersion", "feedbackId", "effectStatus", "issueDraft", "creationAuthorized", "message"], + additionalProperties: false + ) + default: + return InputSchema(type: "object", properties: [:]) + } + } + + private static func stringSchema( + _ description: String, + minLength: Int? = nil, + maxLength: Int? = nil + ) -> InputSchema { + InputSchema(type: "string", description: description, minLength: minLength, maxLength: maxLength) + } + + private static func runtimeContextSchema() -> InputSchema { + InputSchema( + type: "object", + properties: [ + "user": stringSchema("Optional user identifier."), + "organization": stringSchema("Optional organization name."), + "workspace": stringSchema("Optional workspace name."), + "repository": stringSchema("Optional owner/repository identifier."), ], - "discover_skills": [ - ("query", "New intent or event detail."), ("event", "Optional canonical or freeform event name."), - ("context", "Optional JSON runtime context object."), - ("current_skill_ids", "Optional JSON array of currently active skill IDs."), - ("available_tools", "Optional JSON array of provider-neutral tool inventory objects.") + additionalProperties: false + ) + } + + private static func runtimeToolInventoryItemSchema() -> InputSchema { + InputSchema( + type: "object", + properties: [ + "server": stringSchema("MCP server name.", minLength: 1), + "name": stringSchema("Tool name.", minLength: 1), + "description": stringSchema("Optional tool description."), + "inputSchema": stringSchema("Optional serialized tool input schema."), + "provider": stringSchema("Optional provider name."), ], - "get_skill": [("skill_id", "Stable skill ID."), ("version", "Optional exact semantic version.")], - "list_capabilities": [("available_tools", "Optional JSON array of provider-neutral tool inventory objects."), ("skill_id", "Optional skill ID whose requirements should be resolved.")], - "report_skill_feedback": [ - ("skill_id", "Stable skill ID."), ("version", "Observed skill version."), - ("category", "Feedback category."), ("summary", "Concise problem summary."), - ("evidence", "Optional reproducible evidence."), ("suggested_change", "Optional suggested improvement."), - ("create_issue", "Set to true to request authorized external issue creation.") - ] - ] - let properties = Dictionary(uniqueKeysWithValues: (definitions[name] ?? []).map { key, description in - (key, ToolSchemaPayload.Prop(type: "string", description: description)) - }) - let payload = ToolSchemaPayload(type: "object", properties: properties, additionalProperties: false) - return (try? encoderString(payload)) ?? #"{"type":"object","properties":{}}"# + required: ["server", "name"], + additionalProperties: false + ) + } + + private static func arrayOfObjectsSchema() -> InputSchema { + InputSchema(type: "array", items: InputSchema(type: "object", additionalProperties: true)) } private static func encoderString(_ value: T) throws -> String { diff --git a/services/mcp-gateway/Sources/App/MCP/JSONRPCRequest.swift b/services/mcp-gateway/Sources/App/MCP/JSONRPCRequest.swift index 66172cc..c4b734f 100644 --- a/services/mcp-gateway/Sources/App/MCP/JSONRPCRequest.swift +++ b/services/mcp-gateway/Sources/App/MCP/JSONRPCRequest.swift @@ -14,6 +14,11 @@ typealias ToolsListResult = MCPToolsListResult typealias MCPTool = MCPServerKit.MCPTool typealias InputSchema = MCPInputSchema typealias PropertySchema = MCPPropertySchema +typealias JSONValue = MCPJSONValue +typealias ToolCallResult = MCPToolCallResult +typealias ToolContentItem = MCPToolTextContent +typealias MCPToolContent = MCPServerKit.MCPToolContent +typealias MCPToolResourceLink = MCPToolResourceLinkContent typealias ResourcesListResult = MCPResourcesListResult typealias MCPResource = MCPServerKit.MCPResource typealias PromptsListResult = MCPPromptsListResult @@ -23,6 +28,7 @@ typealias PromptArgument = MCPPromptArgument extension MCPJSONRPCID: @retroactive Content {} extension MCPRequest: @retroactive Content {} extension MCPRequestParams: @retroactive Content {} +extension MCPJSONValue: @retroactive Content {} extension MCPErrorObject: @retroactive Content {} extension MCPInitializeResult: @retroactive Content {} extension MCPServerCapabilities: @retroactive Content {} @@ -32,8 +38,13 @@ extension MCPPromptsCapability: @retroactive Content {} extension MCPServerInfo: @retroactive Content {} extension MCPToolsListResult: @retroactive Content {} extension MCPServerKit.MCPTool: @retroactive Content {} -extension MCPInputSchema: @retroactive Content {} -extension MCPPropertySchema: @retroactive Content {} +extension MCPJSONSchema: @retroactive Content {} +extension MCPToolIcon: @retroactive Content {} +extension MCPToolAnnotations: @retroactive Content {} +extension MCPToolTextContent: @retroactive Content {} +extension MCPServerKit.MCPToolContent: @retroactive Content {} +extension MCPToolResourceLinkContent: @retroactive Content {} +extension MCPToolCallResult: @retroactive Content {} extension MCPResourcesListResult: @retroactive Content {} extension MCPServerKit.MCPResource: @retroactive Content {} extension MCPPromptsListResult: @retroactive Content {} @@ -48,9 +59,17 @@ struct ResourceContents: Content { let uri: String let mimeType: String? let text: String? + let blob: String? + + init(uri: String, mimeType: String?, text: String? = nil, blob: String? = nil) { + self.uri = uri + self.mimeType = mimeType + self.text = text + self.blob = blob + } enum CodingKeys: String, CodingKey { - case uri, text + case uri, text, blob case mimeType = "mimeType" } } @@ -70,14 +89,4 @@ struct PromptMessageContent: Content { let text: String? } -struct ToolCallResult: Content { - let content: [ContentItem] - let isError: Bool? -} - -struct ContentItem: Content { - let type: String - let text: String? -} - typealias JSONRPCError = MCPErrorObject diff --git a/services/mcp-gateway/Sources/App/MCP/MCPAgentCopy.swift b/services/mcp-gateway/Sources/App/MCP/MCPAgentCopy.swift index 8771f50..d5925c9 100644 --- a/services/mcp-gateway/Sources/App/MCP/MCPAgentCopy.swift +++ b/services/mcp-gateway/Sources/App/MCP/MCPAgentCopy.swift @@ -34,9 +34,9 @@ enum MCPAgentCopy { static func initializeInstructions(projectName: String, projectDashboardURL: String?) -> String { var lines: [String] = [ "You are connected to MyContextProtocol project \"\(projectName)\".", - "Discovery: before choosing project-specific skills, call tool `\(MCPConstants.catalogToolName)` with the current user task (`mode=route`, `task=...`) to rank relevant skills across tools, resources, and prompts.", - "To load any full SKILL.md body through the tool path, call `\(MCPConstants.catalogToolName)` with `mode=skill` and `skill=`.", - "Compiled tools and prompts use the SKILL.md package slug as the MCP name (no `skill:` prefix).", + "Start by calling `\(MCPConstants.resolveContextToolName)` with the current user request and the tools available in your session. It returns active and suggested skills, conflicts, provenance, capability bindings, and a resolution trace.", + "Use `\(MCPConstants.getSkillToolName)` when you need the complete versioned skill document, and `\(MCPConstants.reportSkillFeedbackToolName)` when observed guidance is missing, ambiguous, incorrect, conflicting, or outdated.", + "Projects with the explicit legacy compiled-tools switch may additionally expose per-skill tools using the SKILL.md package slug (no `skill:` prefix).", "Prefer tools for callable procedures; use resources for long markdown context (`resources/read` with `ctx://skill/...` URIs); prompts expose reusable guidance templates.", ] if let dash = projectDashboardURL, !dash.isEmpty { diff --git a/services/mcp-gateway/Sources/App/MCP/MCPConstants.swift b/services/mcp-gateway/Sources/App/MCP/MCPConstants.swift index 315899e..26de5ee 100644 --- a/services/mcp-gateway/Sources/App/MCP/MCPConstants.swift +++ b/services/mcp-gateway/Sources/App/MCP/MCPConstants.swift @@ -1,10 +1,25 @@ import Foundation enum MCPConstants { - /// Synthetic MCP discovery tool. Colon-free so editors/clients that mishandle `:` in tool names stay compatible. + /// Hidden one-release compatibility alias for the former catalog surface. static let catalogToolName = "mycontext_catalog" - static let runtimeToolNames = ["resolve_context", "discover_skills", "get_skill", "list_capabilities", "report_skill_feedback"] - static let serverVersion = "1.1.0" + static let resolveContextToolName = "resolve_context" + static let getSkillToolName = "get_skill" + static let reportSkillFeedbackToolName = "report_skill_feedback" + + /// The only runtime tools advertised to agents by default. + static let runtimeToolNames = [resolveContextToolName, getSkillToolName, reportSkillFeedbackToolName] + + /// Compatibility names remain callable for one release, but are intentionally omitted from `tools/list`. + static let hiddenRuntimeToolAliases = [catalogToolName, "discover_skills", "list_capabilities"] + static let callableRuntimeToolNames = runtimeToolNames + hiddenRuntimeToolAliases + static let reservedRuntimeToolNames = Set(callableRuntimeToolNames) + static let legacyCompiledToolsPreferenceKey = "legacy_compiled_tools_enabled" + static let serverVersion = "1.2.0" + + static func isReservedRuntimeToolName(_ name: String) -> Bool { + reservedRuntimeToolNames.contains(name) + } /// Wire name for a compiled skill exposed as an MCP tool or prompt (the `SKILL.md` package slug only). static func compiledCapabilityWireName(skillSlug: String) -> String { diff --git a/services/mcp-gateway/Sources/App/MCP/MCPPaginator.swift b/services/mcp-gateway/Sources/App/MCP/MCPPaginator.swift new file mode 100644 index 0000000..2e48b21 --- /dev/null +++ b/services/mcp-gateway/Sources/App/MCP/MCPPaginator.swift @@ -0,0 +1,59 @@ +import Foundation + +struct MCPPaginationPage { + let items: [Element] + let nextCursor: String? +} + +enum MCPPaginationError: Error { + case invalidCursor +} + +enum MCPPaginator { + private struct Cursor: Codable { + let scope: String + let offset: Int + } + + static func page( + _ items: [Element], + cursor: String?, + scope: String, + pageSize: Int = 50 + ) throws -> MCPPaginationPage { + guard pageSize > 0 else { throw MCPPaginationError.invalidCursor } + let offset: Int + if let cursor, !cursor.isEmpty { + guard let decoded = decode(cursor), decoded.scope == scope, + decoded.offset >= 0, decoded.offset < items.count else { + throw MCPPaginationError.invalidCursor + } + offset = decoded.offset + } else { + offset = 0 + } + let end = min(items.count, offset + pageSize) + let next = end < items.count ? encode(Cursor(scope: scope, offset: end)) : nil + return MCPPaginationPage(items: Array(items[offset.. String? { + guard let data = try? JSONEncoder().encode(cursor) else { return nil } + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private static func decode(_ raw: String) -> Cursor? { + guard raw.count <= 1_024, + raw.unicodeScalars.allSatisfy({ CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_").contains($0) }) else { + return nil + } + var base64 = raw.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + let padding = (4 - base64.count % 4) % 4 + base64 += String(repeating: "=", count: padding) + guard let data = Data(base64Encoded: base64) else { return nil } + return try? JSONDecoder().decode(Cursor.self, from: data) + } +} diff --git a/services/mcp-gateway/Sources/App/MCP/MCPProtocolVersion.swift b/services/mcp-gateway/Sources/App/MCP/MCPProtocolVersion.swift deleted file mode 100644 index c1f1018..0000000 --- a/services/mcp-gateway/Sources/App/MCP/MCPProtocolVersion.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -/// Negotiates MCP `protocolVersion` for the initialize handshake. -enum MCPProtocolVersion { - /// Versions this server is tested against (newest first). - static let supportedDescending = ["2025-06-18", "2024-11-05"] - - static func negotiated(requested: String?) -> String { - let trimmed = requested?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty, supportedDescending.contains(trimmed) { - return trimmed - } - return "2024-11-05" - } -} diff --git a/services/mcp-gateway/Sources/App/MCP/ToolHandlers.swift b/services/mcp-gateway/Sources/App/MCP/ToolHandlers.swift index e3e7a5c..34fee07 100644 --- a/services/mcp-gateway/Sources/App/MCP/ToolHandlers.swift +++ b/services/mcp-gateway/Sources/App/MCP/ToolHandlers.swift @@ -1,25 +1,64 @@ import Fluent +import Foundation import Vapor +struct ToolHandlerOutput { + let text: String + let structuredContent: JSONValue? + let content: [MCPToolContent] + + init(text: String, structuredContent: JSONValue?, additionalContent: [MCPToolContent] = []) { + self.text = text + self.structuredContent = structuredContent + self.content = [.text(ToolContentItem(text: text))] + additionalContent + } + + static func text(_ text: String) -> ToolHandlerOutput { + ToolHandlerOutput(text: text, structuredContent: nil) + } +} + struct ToolHandlers { - static func handle(name: String, arguments: [String: String], db: Database, projectId: UUID) async throws -> String { - if name == MCPConstants.catalogToolName { - return try await McpCatalogRouter.route(arguments: arguments, db: db, projectId: projectId) - } - if MCPConstants.runtimeToolNames.contains(name) { + static func handle( + name: String, + arguments: [String: JSONValue], + db: Database, + projectId: UUID + ) async throws -> ToolHandlerOutput { + if MCPConstants.callableRuntimeToolNames.contains(name) { return try await SkillRuntimeToolHandlers.handle(name: name, arguments: arguments, db: db, projectId: projectId) } // Legacy colon-prefixed names are no longer accepted on the wire. if name.contains(":") { throw ToolHandlerError.unknownTool(name: name) } + guard try await legacyCompiledToolsEnabled(db: db, projectId: projectId) else { + throw ToolHandlerError.unknownTool(name: name) + } return try await handleCompiledTool(name: name, arguments: arguments, db: db, projectId: projectId) } - private static func handleCompiledTool(name: String, arguments: [String: String], db: Database, projectId: UUID) async throws -> String { + static func legacyCompiledToolsEnabled(db: Database, projectId: UUID) async throws -> Bool { + guard let settings = try await ProjectRuntimeSettings.query(on: db) + .filter(\.$project.$id == projectId) + .first(), + let raw = settings.providerPreferencesJson, + let data = raw.data(using: .utf8), + let preferences = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return preferences[MCPConstants.legacyCompiledToolsPreferenceKey] as? Bool == true + } + + private static func handleCompiledTool( + name: String, + arguments: [String: JSONValue], + db: Database, + projectId: UUID + ) async throws -> ToolHandlerOutput { let project = try await Project.find(projectId, on: db) guard let releaseId = project?.activeReleaseId else { - return "No active release" + return .text("No active release") } let compiledIds = try await CompiledSkill.query(on: db) @@ -29,7 +68,7 @@ struct ToolHandlers { .compactMap(\.id) guard !compiledIds.isEmpty else { - return "No active release" + return .text("No active release") } guard let cap = try await CapabilityDef.query(on: db) @@ -38,11 +77,11 @@ struct ToolHandlers { .filter(\.$type == "tool") .with(\.$compiledSkill) .first() else { - return "Skill not found: \(name)" + return .text("Skill not found: \(name)") } let compiled = cap.compiledSkill - let detailRaw = arguments["detail"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let detailRaw = arguments["detail"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) let detail = (detailRaw?.isEmpty == false) ? detailRaw : nil var lines = [ "Skill: \(compiled.name)", @@ -57,8 +96,9 @@ struct ToolHandlers { lines.append("---") lines.append(body) } - return lines.joined(separator: "\n") + return .text(lines.joined(separator: "\n")) } + } enum ToolHandlerError: Error { diff --git a/services/mcp-gateway/Sources/App/Migrations/HardenPortableSkillRuntime.swift b/services/mcp-gateway/Sources/App/Migrations/HardenPortableSkillRuntime.swift new file mode 100644 index 0000000..2edf149 --- /dev/null +++ b/services/mcp-gateway/Sources/App/Migrations/HardenPortableSkillRuntime.swift @@ -0,0 +1,91 @@ +import Fluent +import SQLKit + +/// Additive corrections for the portable Agent Skills runtime. Existing columns and tables are +/// intentionally retained so a rolling deployment can read releases produced by either version. +struct HardenPortableSkillRuntime: AsyncMigration { + func prepare(on database: Database) async throws { + try await database.schema(CompiledSkill.schema) + .field("source_policy_json", .string) + .update() + try await database.schema(CompiledSkill.schema) + .field("source_policy_base_checksum", .string) + .update() + try await database.schema(CompiledSkill.schema) + .field("source_policy_stale", .bool, .required, .sql(.default(false))) + .update() + + try await database.schema(SkillRuntimeOverride.schema) + .field("base_checksum", .string) + .update() + try await database.schema(SkillRuntimeOverride.schema) + .field("is_stale", .bool, .required, .sql(.default(false))) + .update() + if let sql = database as? any SQLDatabase { + try await sql.raw( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_overrides_project_fallback + ON skill_runtime_overrides(project_id, skill_id, scope) + WHERE repo_connection_id IS NULL + """ + ).run() + try await sql.raw( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_overrides_repository + ON skill_runtime_overrides(project_id, repo_connection_id, skill_id, scope) + WHERE repo_connection_id IS NOT NULL + """ + ).run() + } + + // A previous binary can continue inserting rows during a rolling deployment, but the + // legacy sentinel never matches runtime context. New writes always supply exact identity. + try await database.schema(SkillAssignment.schema) + .field("target_type", .string, .required, .sql(.default("legacy_unscoped"))) + .update() + try await database.schema(SkillAssignment.schema) + .field("target_id", .string, .required, .sql(.default("legacy_unscoped"))) + .update() + if let sql = database as? any SQLDatabase { + try await sql.raw( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_skill_assignments_target + ON skill_assignments(project_id, skill_id, scope, target_type, target_id) + """ + ).run() + } + try await database.schema(SkillPackageFile.schema) + .id() + .field("skill_package_id", .uuid, .required, .references(SkillPackage.schema, "id", onDelete: .cascade)) + .field("path", .string, .required) + .field("content", .data, .required) + .field("content_type", .string) + .field("byte_count", .int, .required) + .field("checksum", .string, .required) + .field("created_at", .datetime) + .unique(on: "skill_package_id", "path") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(SkillPackageFile.schema).delete() + if let sql = database as? any SQLDatabase { + try await sql.raw("DROP INDEX IF EXISTS uq_skill_assignments_target").run() + try await sql.raw("DROP INDEX IF EXISTS uq_skill_overrides_repository").run() + try await sql.raw("DROP INDEX IF EXISTS uq_skill_overrides_project_fallback").run() + } + try await database.schema(SkillAssignment.schema) + .deleteField("target_type") + .deleteField("target_id") + .update() + try await database.schema(SkillRuntimeOverride.schema) + .deleteField("base_checksum") + .deleteField("is_stale") + .update() + try await database.schema(CompiledSkill.schema) + .deleteField("source_policy_json") + .deleteField("source_policy_base_checksum") + .deleteField("source_policy_stale") + .update() + } +} diff --git a/services/mcp-gateway/Sources/App/Models/CompiledSkill.swift b/services/mcp-gateway/Sources/App/Models/CompiledSkill.swift index 5166cc5..d4d9eac 100644 --- a/services/mcp-gateway/Sources/App/Models/CompiledSkill.swift +++ b/services/mcp-gateway/Sources/App/Models/CompiledSkill.swift @@ -51,6 +51,9 @@ final class CompiledSkill: Model, Content { @OptionalField(key: "priority") var priority: Int? @OptionalField(key: "version") var version: String? @OptionalField(key: "source_checksum") var sourceChecksum: String? + @OptionalField(key: "source_policy_json") var sourcePolicyJson: String? + @OptionalField(key: "source_policy_base_checksum") var sourcePolicyBaseChecksum: String? + @Field(key: "source_policy_stale") var sourcePolicyStale: Bool @OptionalField(key: "canonical_json") var canonicalJson: String? @OptionalField(key: "clarification_json") var clarificationJson: String? @Field(key: "clarification_required") var clarificationRequired: Bool @@ -103,6 +106,7 @@ final class CompiledSkill: Model, Content { self.yamlFrontmatterPresent = yamlFrontmatterPresent self.canonicalSchemaVersion = CompiledSkillDocument.currentSchemaVersion self.clarificationRequired = true + self.sourcePolicyStale = false self.bodyDiffUnified = bodyDiffUnified self.bodyDiffPriorReleaseId = bodyDiffPriorReleaseId } diff --git a/services/mcp-gateway/Sources/App/Models/SkillRuntimeModels.swift b/services/mcp-gateway/Sources/App/Models/SkillRuntimeModels.swift index d3e760a..fb87b9a 100644 --- a/services/mcp-gateway/Sources/App/Models/SkillRuntimeModels.swift +++ b/services/mcp-gateway/Sources/App/Models/SkillRuntimeModels.swift @@ -10,6 +10,8 @@ final class SkillRuntimeOverride: Model, Content, @unchecked Sendable { @Field(key: "scope") var scope: String @Field(key: "metadata_json") var metadataJson: String @OptionalField(key: "source_checksum") var sourceChecksum: String? + @OptionalField(key: "base_checksum") var baseChecksum: String? + @Field(key: "is_stale") var isStale: Bool @OptionalField(key: "writeback_pr_url") var writebackPrUrl: String? @Timestamp(key: "created_at", on: .create) var createdAt: Date? @Timestamp(key: "updated_at", on: .update) var updatedAt: Date? @@ -24,12 +26,27 @@ final class SkillAssignment: Model, Content, @unchecked Sendable { @Field(key: "skill_id") var skillId: String @Field(key: "scope") var scope: String @Field(key: "activation_mode") var activationMode: String + @Field(key: "target_type") var targetType: String + @Field(key: "target_id") var targetId: String @Field(key: "required") var required: Bool @Field(key: "priority") var priority: Int @Timestamp(key: "created_at", on: .create) var createdAt: Date? init() {} } +final class SkillPackageFile: Model, Content, @unchecked Sendable { + static let schema = "skill_package_files" + @ID(key: .id) var id: UUID? + @Parent(key: "skill_package_id") var skillPackage: SkillPackage + @Field(key: "path") var path: String + @Field(key: "content") var content: Data + @OptionalField(key: "content_type") var contentType: String? + @Field(key: "byte_count") var byteCount: Int + @Field(key: "checksum") var checksum: String + @Timestamp(key: "created_at", on: .create) var createdAt: Date? + init() {} +} + final class ProjectRuntimeSettings: Model, Content, @unchecked Sendable { static let schema = "project_runtime_settings" @ID(key: .id) var id: UUID? diff --git a/services/mcp-gateway/Sources/App/Runtime/CompiledSkillDocument.swift b/services/mcp-gateway/Sources/App/Runtime/CompiledSkillDocument.swift index b5ce1c7..75892e5 100644 --- a/services/mcp-gateway/Sources/App/Runtime/CompiledSkillDocument.swift +++ b/services/mcp-gateway/Sources/App/Runtime/CompiledSkillDocument.swift @@ -21,6 +21,31 @@ struct SkillRequirement: Codable, Equatable, Sendable { var capability: String var required: Bool var onMissing: MissingCapabilityFallback + + enum CodingKeys: String, CodingKey { + case capability, required + case onMissing = "on_missing" + } + + private enum LegacyCodingKeys: String, CodingKey { case onMissing } + + init(capability: String, required: Bool, onMissing: MissingCapabilityFallback) { + self.capability = capability + self.required = required + self.onMissing = onMissing + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + capability = try values.decode(String.self, forKey: .capability) + required = try values.decode(Bool.self, forKey: .required) + if let value = try values.decodeIfPresent(MissingCapabilityFallback.self, forKey: .onMissing) { + onMissing = value + } else { + let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self) + onMissing = try legacy.decode(MissingCapabilityFallback.self, forKey: .onMissing) + } + } } struct SkillSource: Codable, Equatable, Sendable { @@ -46,6 +71,8 @@ struct CompiledSkillDocument: Codable, Equatable, Sendable { var kind: SkillKind var scope: SkillScope var activation: SkillActivation + /// Negative routing hints are part of the canonical document so every resolver surface applies them. + var avoidWhen: [String]? = nil var enforcement: SkillEnforcement var priority: Int var requires: [SkillRequirement] @@ -54,6 +81,9 @@ struct CompiledSkillDocument: Codable, Equatable, Sendable { var source: SkillSource var version: String var lifecycle: String? + /// Lossless JSON copy of standards-compliant front matter, including optional fields this + /// runtime does not interpret yet (for example license, compatibility, metadata, allowed-tools). + var standardFrontmatterJson: String? = nil var validation: SkillValidationState } diff --git a/services/mcp-gateway/Sources/App/Runtime/SkillCanonicalCompiler.swift b/services/mcp-gateway/Sources/App/Runtime/SkillCanonicalCompiler.swift index 314e861..0d75341 100644 --- a/services/mcp-gateway/Sources/App/Runtime/SkillCanonicalCompiler.swift +++ b/services/mcp-gateway/Sources/App/Runtime/SkillCanonicalCompiler.swift @@ -1,17 +1,42 @@ import Fluent import Foundation import Vapor +import Yams -struct SkillRuntimeOverridePatch: Codable, Content, Sendable { +struct SkillRuntimeOverridePatch: Codable, Content, Equatable, Sendable { + var exposure: String? var kind: SkillKind? var scope: SkillScope? var activation: SkillActivation? var enforcement: SkillEnforcement? var priority: Int? var requires: [SkillRequirement]? + var avoidWhen: [String]? var conflictsWith: [String]? var version: String? var lifecycle: String? + + enum CodingKeys: String, CodingKey { + case exposure, kind, scope, activation, enforcement, priority, requires, version, lifecycle + case avoidWhen = "avoid_when" + case conflictsWith = "conflicts_with" + } +} + +struct SkillSourcePolicy: Codable, Equatable, Sendable { + var baseChecksum: String? + var metadata: SkillRuntimeOverridePatch + + enum CodingKeys: String, CodingKey { + case baseChecksum = "base_checksum" + case metadata + } +} + +struct SkillSourcePolicyState: Sendable { + var policy: SkillSourcePolicy + var rawJson: String + var stale: Bool } enum SkillCanonicalCompiler { @@ -20,17 +45,22 @@ enum SkillCanonicalCompiler { package: SkillPackage, repository: String?, revision: String?, + sourcePolicy: SkillSourcePolicy? = nil, override: SkillRuntimeOverridePatch? = nil ) -> (document: CompiledSkillDocument, questions: [SkillClarificationQuestion]) { var missing: [String] = [] - if parsed.kind == nil, override?.kind == nil { missing.append("kind") } - if parsed.scope == nil, override?.scope == nil { missing.append("scope") } - if parsed.activation == nil, override?.activation == nil { missing.append("activation") } - if parsed.enforcement == nil, override?.enforcement == nil { missing.append("enforcement") } - if parsed.version == nil, override?.version == nil { missing.append("version") } + // A standard Agent Skills package only requires name and description. Portable runtime + // fields are optional and receive safe, useful defaults instead of blocking publication. + if !parsed.hadYamlFrontmatter { + if parsed.kind == nil, sourcePolicy?.metadata.kind == nil, override?.kind == nil { missing.append("kind") } + if parsed.scope == nil, sourcePolicy?.metadata.scope == nil, override?.scope == nil { missing.append("scope") } + if parsed.activation == nil, sourcePolicy?.metadata.activation == nil, override?.activation == nil { missing.append("activation") } + if parsed.enforcement == nil, sourcePolicy?.metadata.enforcement == nil, override?.enforcement == nil { missing.append("enforcement") } + if parsed.version == nil, sourcePolicy?.metadata.version == nil, override?.version == nil { missing.append("version") } + } - let activation = override?.activation ?? parsed.activation ?? SkillActivation( - mode: .explicit, + let activation = override?.activation ?? sourcePolicy?.metadata.activation ?? parsed.activation ?? SkillActivation( + mode: parsed.hadYamlFrontmatter ? .intent : .explicit, intents: parsed.useWhen ?? [], events: [], tags: [], @@ -38,22 +68,25 @@ enum SkillCanonicalCompiler { ) let checksum = parsed.hash ?? "unknown" let description = parsed.description ?? String(parsed.body.prefix(200)) + let generatedVersion = version(revision: revision, checksum: checksum) let document = CompiledSkillDocument( schemaVersion: CompiledSkillDocument.currentSchemaVersion, id: package.name, name: package.name, description: description, - kind: override?.kind ?? parsed.kind ?? .reference, - scope: override?.scope ?? parsed.scope ?? .task, + kind: override?.kind ?? sourcePolicy?.metadata.kind ?? parsed.kind ?? .task, + scope: override?.scope ?? sourcePolicy?.metadata.scope ?? parsed.scope ?? .task, activation: activation, - enforcement: override?.enforcement ?? parsed.enforcement ?? .advisory, - priority: min(100, max(0, override?.priority ?? parsed.priority ?? 50)), - requires: override?.requires ?? parsed.requires, - conflictsWith: override?.conflictsWith ?? parsed.conflictsWith, + avoidWhen: override?.avoidWhen ?? sourcePolicy?.metadata.avoidWhen ?? parsed.avoidWhen, + enforcement: override?.enforcement ?? sourcePolicy?.metadata.enforcement ?? parsed.enforcement ?? .advisory, + priority: min(100, max(0, override?.priority ?? sourcePolicy?.metadata.priority ?? parsed.priority ?? 50)), + requires: override?.requires ?? sourcePolicy?.metadata.requires ?? parsed.requires, + conflictsWith: override?.conflictsWith ?? sourcePolicy?.metadata.conflictsWith ?? parsed.conflictsWith, instructions: parsed.body, source: SkillSource(repository: repository, path: parsed.path, revision: revision, checksum: checksum), - version: override?.version ?? parsed.version ?? "0.0.0", - lifecycle: override?.lifecycle ?? parsed.lifecycle, + version: override?.version ?? sourcePolicy?.metadata.version ?? parsed.version ?? generatedVersion, + lifecycle: override?.lifecycle ?? sourcePolicy?.metadata.lifecycle ?? parsed.lifecycle, + standardFrontmatterJson: parsed.rawFrontmatterJson, validation: SkillValidationState( clarificationRequired: !missing.isEmpty, missingFields: missing, @@ -63,6 +96,110 @@ enum SkillCanonicalCompiler { return (document, questionsForRuntime(fields: missing)) } + static func sourcePolicies(repoRoot: URL) throws -> [String: SkillSourcePolicyState] { + let url = repoRoot.appendingPathComponent(".mycontext/skills.yaml") + guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw SkillSourcePolicyError.unsafeFile + } + guard (values.fileSize ?? 0) <= 256 * 1024 else { + throw SkillSourcePolicyError.fileTooLarge + } + let yaml = try String(contentsOf: url, encoding: .utf8) + let loaded: Any? + do { + loaded = try load(yaml: yaml) + } catch { + throw SkillSourcePolicyError.invalidYAML(error.localizedDescription) + } + guard let root = loaded as? [String: Any] else { + throw SkillSourcePolicyError.invalidRoot("the document root must be a mapping") + } + guard let version = Self.integer(root["version"]) else { + throw SkillSourcePolicyError.invalidRoot("version is required and must be an integer") + } + guard version == 1 else { + throw SkillSourcePolicyError.unsupportedVersion(version) + } + guard root.keys.contains("skills"), let skills = root["skills"] as? [String: Any] else { + throw SkillSourcePolicyError.invalidRoot("skills is required and must be a mapping") + } + var result: [String: SkillSourcePolicyState] = [:] + for (skillId, value) in skills.sorted(by: { $0.key < $1.key }) { + guard Self.isValidPolicySkillId(skillId) else { + throw SkillSourcePolicyError.invalidEntry(skillId, "skill id must be a lowercase ASCII slug of at most 64 characters") + } + guard let object = value as? [String: Any] else { + throw SkillSourcePolicyError.invalidEntry(skillId, "entry must be a mapping") + } + guard JSONSerialization.isValidJSONObject(object) else { + throw SkillSourcePolicyError.invalidEntry(skillId, "entry contains values that cannot be represented as JSON") + } + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + } catch { + throw SkillSourcePolicyError.invalidEntry(skillId, error.localizedDescription) + } + let decoded: SkillSourcePolicy + do { + decoded = try JSONDecoder().decode(SkillSourcePolicy.self, from: data) + } catch { + throw SkillSourcePolicyError.invalidEntry(skillId, Self.decodingDescription(error)) + } + if let exposure = decoded.metadata.exposure, + !["tool", "resource", "prompt"].contains(exposure) { + throw SkillSourcePolicyError.invalidEntry(skillId, "metadata.exposure must be tool, resource, or prompt") + } + if let priority = decoded.metadata.priority, !(0...100).contains(priority) { + throw SkillSourcePolicyError.invalidEntry(skillId, "metadata.priority must be between 0 and 100") + } + guard let raw = String(data: data, encoding: .utf8) else { + throw SkillSourcePolicyError.invalidEntry(skillId, "entry could not be encoded as UTF-8") + } + result[skillId] = .init(policy: decoded, rawJson: raw, stale: false) + } + return result + } + + private static func integer(_ value: Any?) -> Int? { + if let value = value as? Int { return value } + if let value = value as? NSNumber { + let double = value.doubleValue + guard double.rounded() == double else { return nil } + return value.intValue + } + return nil + } + + private static func isValidPolicySkillId(_ value: String) -> Bool { + guard !value.isEmpty, value.count <= 64 else { return false } + let range = NSRange(value.startIndex..., in: value) + guard let match = Validator.allowedNamePattern.firstMatch(in: value, range: range), + NSEqualRanges(match.range, range) else { return false } + return !MCPConstants.isReservedRuntimeToolName(value) + } + + private static func decodingDescription(_ error: Error) -> String { + switch error { + case let DecodingError.typeMismatch(_, context), + let DecodingError.valueNotFound(_, context), + let DecodingError.keyNotFound(_, context), + let DecodingError.dataCorrupted(context): + let path = context.codingPath.map(\.stringValue).joined(separator: ".") + return path.isEmpty ? context.debugDescription : "\(path): \(context.debugDescription)" + default: + return error.localizedDescription + } + } + + private static func version(revision: String?, checksum: String) -> String { + let trimmed = revision?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let revisionPart = trimmed.isEmpty ? "unversioned" : trimmed + return "\(revisionPart)+\(checksum.prefix(12))" + } + static func questionsForRuntime(fields: [String]) -> [SkillClarificationQuestion] { fields.compactMap { field in switch field { @@ -76,3 +213,29 @@ enum SkillCanonicalCompiler { } } } + +enum SkillSourcePolicyError: Error, LocalizedError, Equatable { + case unsafeFile + case fileTooLarge + case invalidYAML(String) + case invalidRoot(String) + case unsupportedVersion(Int) + case invalidEntry(String, String) + + var errorDescription: String? { + switch self { + case .unsafeFile: + return ".mycontext/skills.yaml must be a regular, non-symbolic-link file" + case .fileTooLarge: + return ".mycontext/skills.yaml exceeds the 256 KiB size limit" + case .invalidYAML(let reason): + return ".mycontext/skills.yaml contains invalid YAML: \(reason)" + case .invalidRoot(let reason): + return ".mycontext/skills.yaml is invalid: \(reason)" + case .unsupportedVersion(let version): + return ".mycontext/skills.yaml version \(version) is unsupported; expected version 1" + case .invalidEntry(let skillId, let reason): + return ".mycontext/skills.yaml entry skills.\(skillId) is invalid: \(reason)" + } + } +} diff --git a/services/mcp-gateway/Sources/App/Runtime/SkillPackageResourceService.swift b/services/mcp-gateway/Sources/App/Runtime/SkillPackageResourceService.swift new file mode 100644 index 0000000..1b93788 --- /dev/null +++ b/services/mcp-gateway/Sources/App/Runtime/SkillPackageResourceService.swift @@ -0,0 +1,154 @@ +import Fluent +import Foundation +import Vapor + +struct SkillPackageFileDescriptor: Codable, Sendable { + let path: String + let checksum: String + let mediaType: String + let byteCount: Int + let resourceUri: String +} + +struct SkillPackageResourceReference: Equatable, Sendable { + let skillId: String + let path: String? + let version: String? +} + +enum SkillPackageResourceService { + static func normalize(relativePath raw: String) throws -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= 1_024, + !trimmed.hasPrefix("/"), !trimmed.hasPrefix("~"), !trimmed.contains("\\") else { + throw Abort(.badRequest, reason: "path must be a safe package-relative path") + } + let decoded = trimmed.removingPercentEncoding ?? trimmed + guard decoded == trimmed else { + throw Abort(.badRequest, reason: "path must not contain percent-encoded traversal or separators") + } + let components = trimmed.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + guard !components.isEmpty, + components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." && $0.unicodeScalars.allSatisfy { $0.value >= 0x20 && $0.value != 0x7f } }) else { + throw Abort(.badRequest, reason: "path must be a safe package-relative path") + } + return components.joined(separator: "/") + } + + static func uri(skillId: String, path: String? = nil, version: String? = nil) -> String { + var value = "ctx://skill/\(encodeComponent(skillId))" + if let path { + value += "/file/" + path.split(separator: "/").map { encodeComponent(String($0)) }.joined(separator: "/") + } + if let version { + var queryAllowed = CharacterSet.urlQueryAllowed + queryAllowed.remove(charactersIn: "&=+#%") + guard let encodedVersion = version.addingPercentEncoding(withAllowedCharacters: queryAllowed) else { + return value + } + value += "?version=\(encodedVersion)" + } + return value + } + + static func parse(uri raw: String) throws -> SkillPackageResourceReference? { + guard raw.hasPrefix("ctx://skill/") else { return nil } + guard !raw.contains("#") else { throw Abort(.badRequest, reason: "Invalid skill resource URI") } + let queryParts = raw.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + let resource = String(queryParts[0]) + let version: String? + if queryParts.count == 2 { + let query = String(queryParts[1]) + guard query.hasPrefix("version="), !query.dropFirst("version=".count).contains("&"), + let decodedVersion = String(query.dropFirst("version=".count)).removingPercentEncoding else { + throw Abort(.badRequest, reason: "Invalid skill resource URI") + } + let trimmedVersion = decodedVersion.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedVersion.isEmpty, trimmedVersion.count <= 512 else { + throw Abort(.badRequest, reason: "Invalid skill resource URI") + } + version = trimmedVersion + } else { + version = nil + } + let remainder = String(resource.dropFirst("ctx://skill/".count)) + let pieces = remainder.components(separatedBy: "/file/") + guard pieces.count == 1 || pieces.count == 2, + let skillId = pieces[0].removingPercentEncoding, + !skillId.isEmpty, !skillId.contains("/"), !skillId.contains("\\") else { + throw Abort(.badRequest, reason: "Invalid skill resource URI") + } + if pieces.count == 1 { return .init(skillId: skillId, path: nil, version: version) } + let encodedPath = pieces[1] + let decodedComponents = try encodedPath.split(separator: "/", omittingEmptySubsequences: false).map { component -> String in + guard let decoded = String(component).removingPercentEncoding, + !decoded.contains("/"), !decoded.contains("\\") else { + throw Abort(.badRequest, reason: "Invalid skill resource URI") + } + return decoded + } + return .init( + skillId: skillId, + path: try normalize(relativePath: decodedComponents.joined(separator: "/")), + version: version + ) + } + + static func activeCompiledSkill( + projectId: UUID, + skillId: String, + version: String? = nil, + db: Database + ) async throws -> (CompiledSkill, CompiledSkillDocument) { + guard let releaseId = try await MCPCatalogService.activeReleaseId(projectId: projectId, db: db) else { + throw Abort(.notFound, reason: "The project has no active release") + } + var query = CompiledSkill.query(on: db) + .filter(\.$release.$id == releaseId) + .filter(\.$status == "ready") + .filter(\.$skillId == skillId) + if let version { query = query.filter(\.$version == version) } + guard let row = try await query.first(), + let document = SkillRuntimeJSON.decode(CompiledSkillDocument.self, from: row.canonicalJson) else { + throw Abort(.notFound, reason: "The requested skill or version is not active in this project") + } + return (row, document) + } + + static func files( + for compiled: CompiledSkill, + skillId: String, + version: String, + db: Database + ) async throws -> [SkillPackageFileDescriptor] { + let rows = try await SkillPackageFile.query(on: db) + .filter(\.$skillPackage.$id == compiled.$skillPackage.id) + .sort(\.$path) + .all() + return rows.map { + SkillPackageFileDescriptor( + path: $0.path, + checksum: $0.checksum, + mediaType: $0.contentType ?? "application/octet-stream", + byteCount: $0.byteCount, + resourceUri: uri(skillId: skillId, path: $0.path, version: version) + ) + } + } + + static func file(compiled: CompiledSkill, path: String, db: Database) async throws -> SkillPackageFile { + guard let row = try await SkillPackageFile.query(on: db) + .filter(\.$skillPackage.$id == compiled.$skillPackage.id) + .filter(\.$path == path) + .first() else { + throw Abort(.notFound, reason: "Package file not found") + } + return row + } + + private static func encodeComponent(_ raw: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/?#%\\") + return raw.addingPercentEncoding(withAllowedCharacters: allowed) ?? raw + } +} diff --git a/services/mcp-gateway/Sources/App/Runtime/SkillRuntimeResolver.swift b/services/mcp-gateway/Sources/App/Runtime/SkillRuntimeResolver.swift index b33fb75..00d38b0 100644 --- a/services/mcp-gateway/Sources/App/Runtime/SkillRuntimeResolver.swift +++ b/services/mcp-gateway/Sources/App/Runtime/SkillRuntimeResolver.swift @@ -15,9 +15,11 @@ struct RuntimeContext: Codable, Sendable { var organization: String? = nil var workspace: String? = nil var repository: String? = nil + var task: String? = nil } struct CapabilityBindingResult: Codable, Sendable { + var skillId: String? = nil var capability: String var required: Bool var selectedServer: String? @@ -46,6 +48,14 @@ struct ResolvedSkillResult: Codable, Sendable { struct ResolutionConflict: Codable, Sendable { var skillId: String; var conflictsWith: String; var unresolved: Bool } struct ResolutionTraceStep: Codable, Sendable { var skillId: String?; var outcome: String; var reason: String; var score: Double? } +struct ResolutionNextAction: Codable, Sendable { + var order: Int + var type: String + var skillId: String? + var capability: String? + var instruction: String + var resourceUri: String? +} struct SkillResolutionResponse: Codable, Sendable { var schemaVersion: Int = 1 @@ -57,6 +67,7 @@ struct SkillResolutionResponse: Codable, Sendable { var conflicts: [ResolutionConflict] var missingContext: [String] var eventCanonical: Bool? + var nextActions: [ResolutionNextAction] var resolutionTrace: [ResolutionTraceStep] } @@ -79,16 +90,16 @@ enum SkillRuntimeResolver { ) async throws -> SkillResolutionResponse { let traceId = UUID() guard let project = try await Project.find(projectId, on: db), let releaseId = project.activeReleaseId else { - return .init(traceId: traceId, activeSkills: [], suggestedTaskSkills: [], capabilityBindings: [], missingRequirements: [], conflicts: [], missingContext: ["activeRelease"], eventCanonical: event.map(canonicalEvents.contains), resolutionTrace: []) + return .init(traceId: traceId, activeSkills: [], suggestedTaskSkills: [], capabilityBindings: [], missingRequirements: [], conflicts: [], missingContext: ["activeRelease"], eventCanonical: event.map(canonicalEvents.contains), nextActions: [], resolutionTrace: []) } let rows = try await CompiledSkill.query(on: db) .filter(\.$release.$id == releaseId).filter(\.$status == "ready").all() let assignments = try await SkillAssignment.query(on: db).filter(\.$project.$id == projectId).all() - let settings = try await ProjectRuntimeSettings.query(on: db).filter(\.$project.$id == projectId).first() let assignmentBySkill = Dictionary(grouping: assignments, by: \.skillId) let queryTokens = tokens(request + " " + (event ?? "")) var active: [(ResolvedSkillResult, CompiledSkillDocument)] = [] var suggested: [(ResolvedSkillResult, CompiledSkillDocument)] = [] + var bindings: [CapabilityBindingResult] = [] var trace: [ResolutionTraceStep] = [] for row in rows { @@ -97,28 +108,57 @@ enum SkillRuntimeResolver { continue } let explicitAssignments = assignmentBySkill[document.id] ?? [] - let controllingAssignment = explicitAssignments.sorted { lhs, rhs in + let applicableAssignments = explicitAssignments.filter { + assignmentApplies($0, projectId: projectId, context: context) + } + let controllingAssignment = applicableAssignments.sorted { lhs, rhs in if lhs.required != rhs.required { return lhs.required } if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } return lhs.scope < rhs.scope }.first let isCurrent = currentSkillIds.contains(document.id) - let assigned = !explicitAssignments.isEmpty let eventMatch = event.map { document.activation.events.contains($0) } ?? false let keywordScore = score(document: document, tokens: queryTokens) - let semanticScore = if settings?.semanticEnabled == true { - try await semanticScore(document: document, request: request, projectId: projectId, db: db) - } else { 0.0 } - let combinedScore = keywordScore + semanticScore + let combinedScore = keywordScore let intentMatch = combinedScore > 0 let always = document.activation.mode == .always + let documentActivated = activationMatches( + document.activation.mode.rawValue, + isCurrent: isCurrent, + eventMatch: eventMatch, + intentMatch: intentMatch + ) + let assignmentActivated = controllingAssignment.map { + activationMatches($0.activationMode, isCurrent: isCurrent, eventMatch: eventMatch, intentMatch: intentMatch) + } ?? false + let assigned = controllingAssignment != nil && assignmentActivated + let documentContextApplies = scopeHasContext(document.scope, context: context) + if !isCurrent, avoidMatch(document.avoidWhen ?? [], tokens: queryTokens) { + trace.append(.init(skillId: document.id, outcome: "excluded", reason: "avoid_when_match", score: combinedScore)) + continue + } let automaticEligible = !document.validation.clarificationRequired - let selected = isCurrent || assigned || (automaticEligible && (always || eventMatch || intentMatch)) + let selected = isCurrent || assigned || (automaticEligible && documentContextApplies && documentActivated) guard selected else { - trace.append(.init(skillId: document.id, outcome: "excluded", reason: document.validation.clarificationRequired ? "clarification_required" : "no_activation_match", score: combinedScore)) + let reason = document.validation.clarificationRequired + ? "clarification_required" + : !documentContextApplies && documentActivated + ? "missing_scope_context" + : "no_activation_match" + trace.append(.init(skillId: document.id, outcome: "excluded", reason: reason, score: combinedScore)) continue } - let reason = isCurrent ? "already_active" : assigned ? "explicit_assignment" : always ? "always_active" : eventMatch ? "event_match" : "intent_match" + let documentBindings = document.requires.map { requirement in + var binding = bind(requirement, tools: tools) + binding.skillId = document.id + return binding + } + bindings.append(contentsOf: documentBindings) + if documentBindings.contains(where: { $0.required && $0.missing && $0.fallback == MissingCapabilityFallback.failActivation.rawValue }) { + trace.append(.init(skillId: document.id, outcome: "excluded", reason: "required_capability_missing", score: combinedScore)) + continue + } + let reason = isCurrent ? "already_active" : assigned ? "explicit_assignment" : activationReason(document.activation.mode) let instructions = document.instructions.utf8.count <= inlineLimit ? document.instructions : nil let result = ResolvedSkillResult( id: document.id, version: document.version, kind: document.kind.rawValue, @@ -127,7 +167,11 @@ enum SkillRuntimeResolver { priority: controllingAssignment?.priority ?? document.priority, selectionReason: reason, score: combinedScore, instructions: instructions, contentIncluded: instructions != nil, - resourceUri: CapabilitySchemaBuilder.resourceURI(skillName: document.id), source: document.source + resourceUri: SkillPackageResourceService.uri( + skillId: document.id, + version: document.version + ), + source: document.source ) if isCurrent || assigned || always || (document.kind == .operating && document.enforcement == .required) { active.append((result, document)) @@ -140,8 +184,7 @@ enum SkillRuntimeResolver { active.sort { ordered($0.0, $1.0) } suggested.sort { ordered($0.0, $1.0) } let selected = active + suggested - let bindings = selected.flatMap { pair in pair.1.requires.map { bind($0, tools: tools) } } - let missing = bindings.filter(\.missing).map(\.capability) + let missing = Array(Set(bindings.filter(\.missing).map(\.capability))).sorted() let selectedIds = Set(selected.map { $0.1.id }) let conflicts = selected.flatMap { pair in pair.1.conflictsWith.filter(selectedIds.contains).map { ResolutionConflict(skillId: pair.1.id, conflictsWith: $0, unresolved: true) } @@ -149,26 +192,65 @@ enum SkillRuntimeResolver { let missingContext = [ context.organization == nil ? "organization" : nil, context.workspace == nil ? "workspace" : nil, - context.repository == nil ? "repository" : nil + context.repository == nil ? "repository" : nil, + context.task == nil ? "task" : nil ].compactMap { $0 } + var nextActions: [ResolutionNextAction] = [] + for (result, _) in active { + nextActions.append(.init( + order: nextActions.count + 1, + type: result.contentIncluded ? "apply_skill" : "read_skill", + skillId: result.id, + capability: nil, + instruction: result.contentIncluded + ? "Apply the included \(result.id) instructions before continuing." + : "Read the complete \(result.id) package before continuing.", + resourceUri: result.resourceUri + )) + } + for binding in bindings.filter(\.missing).sorted(by: { $0.capability < $1.capability }) { + let instruction: String + switch MissingCapabilityFallback(rawValue: binding.fallback) { + case .failActivation: instruction = "Do not activate the dependent skill until \(binding.capability) is available." + case .warn: instruction = "Warn that \(binding.capability) is unavailable before continuing." + case .returnDraft: instruction = "Return a draft for \(binding.capability); do not claim the external action occurred." + case .requestProviderSelection: instruction = "Ask the user to select or connect a provider for \(binding.capability)." + case .continueWithoutAction: instruction = "Continue without performing \(binding.capability), and state that no action occurred." + case nil: instruction = "Report that \(binding.capability) is unavailable." + } + nextActions.append(.init( + order: nextActions.count + 1, type: binding.fallback, skillId: binding.skillId, + capability: binding.capability, instruction: instruction, resourceUri: nil + )) + } + for conflict in conflicts.sorted(by: { + $0.skillId == $1.skillId ? $0.conflictsWith < $1.conflictsWith : $0.skillId < $1.skillId + }) { + nextActions.append(.init( + order: nextActions.count + 1, type: "resolve_conflict", skillId: conflict.skillId, + capability: nil, instruction: "Resolve the conflict between \(conflict.skillId) and \(conflict.conflictsWith) before acting.", + resourceUri: nil + )) + } let response = SkillResolutionResponse( traceId: traceId, activeSkills: active.map(\.0), suggestedTaskSkills: suggested.map(\.0), capabilityBindings: bindings, missingRequirements: missing, conflicts: conflicts, - missingContext: missingContext, eventCanonical: event.map(canonicalEvents.contains), resolutionTrace: trace + missingContext: missingContext, eventCanonical: event.map(canonicalEvents.contains), + nextActions: nextActions, resolutionTrace: trace ) try await recordTelemetry(response, request: request, projectId: projectId, db: db) return response } private static func ordered(_ lhs: ResolvedSkillResult, _ rhs: ResolvedSkillResult) -> Bool { - let scopeOrder = ["global": 0, "organization": 1, "workspace": 2, "repository": 3, "task": 4] + let scopeOrder = ["task": 0, "repository": 1, "workspace": 2, "organization": 3, "global": 4] if lhs.enforcement != rhs.enforcement { return lhs.enforcement == "required" } - if (scopeOrder[lhs.scope] ?? 9) != (scopeOrder[rhs.scope] ?? 9) { return (scopeOrder[lhs.scope] ?? 9) < (scopeOrder[rhs.scope] ?? 9) } if lhs.selectionReason != rhs.selectionReason { let explicit = ["already_active", "explicit_assignment"] if explicit.contains(lhs.selectionReason) != explicit.contains(rhs.selectionReason) { return explicit.contains(lhs.selectionReason) } } + if (scopeOrder[lhs.scope] ?? 9) != (scopeOrder[rhs.scope] ?? 9) { return (scopeOrder[lhs.scope] ?? 9) < (scopeOrder[rhs.scope] ?? 9) } if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } if lhs.score != rhs.score { return lhs.score > rhs.score } return lhs.id < rhs.id @@ -185,48 +267,64 @@ enum SkillRuntimeResolver { return fields.reduce(0) { total, field in total + Double(tokens(field.0).intersection(queryTokens).count) * field.1 } } - private static func tokens(_ value: String) -> Set { - Set(value.lowercased().components(separatedBy: CharacterSet.alphanumerics.inverted).filter { $0.count > 2 }) + static func avoidMatch(_ conditions: [String], tokens queryTokens: Set) -> Bool { + conditions.contains { condition in + let negativeTokens = tokens(condition) + return !negativeTokens.isEmpty && negativeTokens.isSubset(of: queryTokens) + } + } + + static func activationMatches(_ mode: String, isCurrent: Bool, eventMatch: Bool, intentMatch: Bool) -> Bool { + switch SkillActivationMode(rawValue: mode) { + case .always: return true + case .explicit: return isCurrent + case .event: return eventMatch + case .intent: return intentMatch + case nil: return false + } } - private static func semanticScore(document: CompiledSkillDocument, request: String, projectId: UUID, db: Database) async throws -> Double { - let provider = "deterministic-fallback" - let model = "token-buckets-v1" - let searchable = [document.id, document.description, document.activation.intents.joined(separator: " "), document.activation.tags.joined(separator: " "), document.activation.examples.joined(separator: " ")].joined(separator: " ") - let skillVector: [Double] - if let record = try await SkillEmbeddingRecord.query(on: db) - .filter(\.$project.$id == projectId).filter(\.$skillId == document.id) - .filter(\.$sourceChecksum == document.source.checksum).filter(\.$provider == provider).filter(\.$model == model).first(), - let stored = SkillRuntimeJSON.decode([Double].self, from: record.vectorJson) { - skillVector = stored - } else { - skillVector = semanticVector(searchable) - try await SkillEmbeddingRecord.query(on: db).filter(\.$project.$id == projectId).filter(\.$skillId == document.id) - .filter(\.$provider == provider).filter(\.$model == model).delete() - let record = SkillEmbeddingRecord(); record.$project.id = projectId; record.skillId = document.id - record.sourceChecksum = document.source.checksum; record.provider = provider; record.model = model - record.vectorJson = SkillRuntimeJSON.encode(skillVector); try await record.save(on: db) + static func scopeHasContext(_ scope: SkillScope, context: RuntimeContext) -> Bool { + switch scope { + case .global: return true + case .organization: return context.organization != nil + case .workspace: return context.workspace != nil + case .repository: return context.repository != nil + case .task: return context.task != nil } - return cosine(semanticVector(request), skillVector) } - private static func semanticVector(_ text: String) -> [Double] { - var vector = Array(repeating: 0.0, count: 64) - for token in tokens(text) { - let digest = SHA256.hash(data: Data(token.utf8)) - let index = digest.withUnsafeBytes { bytes in Int(bytes[0]) } % vector.count - vector[index] += 1 + private static func activationReason(_ mode: SkillActivationMode) -> String { + switch mode { + case .always: return "always_active" + case .event: return "event_match" + case .intent: return "intent_match" + case .explicit: return "explicit_activation" } - let length = sqrt(vector.reduce(0) { $0 + $1 * $1 }) - return length == 0 ? vector : vector.map { $0 / length } } - private static func cosine(_ lhs: [Double], _ rhs: [Double]) -> Double { - guard lhs.count == rhs.count else { return 0 } - return zip(lhs, rhs).reduce(0) { $0 + $1.0 * $1.1 } + private static func assignmentApplies(_ assignment: SkillAssignment, projectId: UUID, context: RuntimeContext) -> Bool { + let target = assignment.targetId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !target.isEmpty else { return false } + if assignment.targetType == "project" { + return target == "project" || target.caseInsensitiveCompare(projectId.uuidString) == .orderedSame + } + guard assignment.targetType == assignment.scope else { return false } + switch SkillScope(rawValue: assignment.scope) { + case .global: return target == "*" || target == "global" + case .organization: return context.organization == target + case .workspace: return context.workspace == target + case .repository: return context.repository == target + case .task: return context.task == target + case nil: return false + } + } + + private static func tokens(_ value: String) -> Set { + Set(value.lowercased().components(separatedBy: CharacterSet.alphanumerics.inverted).filter { $0.count > 2 }) } - private static func bind(_ requirement: SkillRequirement, tools: [RuntimeToolInventoryItem]) -> CapabilityBindingResult { + static func bind(_ requirement: SkillRequirement, tools: [RuntimeToolInventoryItem]) -> CapabilityBindingResult { let parts = Set(requirement.capability.lowercased().split(separator: ".").map(String.init)) var candidates: [(tool: RuntimeToolInventoryItem, score: Int)] = [] for tool in tools { diff --git a/services/mcp-gateway/Sources/App/Runtime/SkillRuntimeToolHandlers.swift b/services/mcp-gateway/Sources/App/Runtime/SkillRuntimeToolHandlers.swift index b8cc0d4..fa03840 100644 --- a/services/mcp-gateway/Sources/App/Runtime/SkillRuntimeToolHandlers.swift +++ b/services/mcp-gateway/Sources/App/Runtime/SkillRuntimeToolHandlers.swift @@ -3,95 +3,281 @@ import Foundation import Vapor enum SkillRuntimeToolHandlers { - static func handle(name: String, arguments: [String: String], db: Database, projectId: UUID) async throws -> String { + private struct CanonicalSkillResponse: Codable { + var schemaVersion = 1 + let kind: String + let id: String + let name: String + let description: String + let instructions: String + let version: String + let checksum: String + let mediaType: String + let resourceUri: String + let source: SkillSource + let files: [SkillPackageFileDescriptor] + } + + private struct SkillFileResponse: Codable { + var schemaVersion = 1 + let kind: String + let id: String + let version: String + let path: String + let checksum: String + let mediaType: String + let byteCount: Int + let resourceUri: String + let text: String? + let blob: String? + let encoding: String + let source: SkillSource + } + + static func handle( + name: String, + arguments: [String: JSONValue], + db: Database, + projectId: UUID + ) async throws -> ToolHandlerOutput { switch name { - case "resolve_context": return try await resolve(arguments, db: db, projectId: projectId) - case "discover_skills": return try await discover(arguments, db: db, projectId: projectId) - case "get_skill": return try await getSkill(arguments, db: db, projectId: projectId) - case "list_capabilities": return try await listCapabilities(arguments, db: db, projectId: projectId) - case "report_skill_feedback": return try await reportFeedback(arguments, db: db, projectId: projectId) + case MCPConstants.resolveContextToolName: return try await resolve(arguments, db: db, projectId: projectId) + case MCPConstants.getSkillToolName: return try await getSkill(arguments, db: db, projectId: projectId) + case MCPConstants.reportSkillFeedbackToolName: return try await reportFeedback(arguments, db: db, projectId: projectId) + case MCPConstants.catalogToolName: return try await legacyCatalog(arguments, db: db, projectId: projectId) + case "discover_skills": return try await legacyDiscover(arguments, db: db, projectId: projectId) + case "list_capabilities": return try await legacyListCapabilities(arguments, db: db, projectId: projectId) default: throw ToolHandlerError.unknownTool(name: name) } } - private static func resolve(_ arguments: [String: String], db: Database, projectId: UUID) async throws -> String { - guard let request = nonempty(arguments["request"]) else { throw Abort(.badRequest, reason: "request is required") } - let context = RuntimeContext( - user: nonempty(arguments["user"]), organization: nonempty(arguments["organization"]), - workspace: nonempty(arguments["workspace"]), repository: nonempty(arguments["repository"]) - ) + private static func resolve( + _ arguments: [String: JSONValue], + db: Database, + projectId: UUID + ) async throws -> ToolHandlerOutput { + guard let request = nonempty(string(arguments["request"])) else { + throw Abort(.badRequest, reason: "request is required") + } + let contextArgument = arguments["context"] + guard contextArgument == nil || decode(RuntimeContext.self, contextArgument) != nil else { + throw Abort(.badRequest, reason: "context must be an object with string identity fields") + } + let currentSkillArgument = arguments["current_skill_ids"] + guard currentSkillArgument == nil || decode([String].self, currentSkillArgument) != nil else { + throw Abort(.badRequest, reason: "current_skill_ids must be an array of strings") + } + let toolsArgument = arguments["available_tools"] + guard toolsArgument == nil || decode([RuntimeToolInventoryItem].self, toolsArgument) != nil else { + throw Abort(.badRequest, reason: "available_tools must be an array of structured tool descriptions") + } + let event = try optionalBoundedString(arguments["event"], field: "event", max: 128) + var context = decode(RuntimeContext.self, contextArgument) ?? .init() + context.user = nonempty(string(arguments["user"])) ?? context.user + context.organization = nonempty(string(arguments["organization"])) ?? context.organization + context.workspace = nonempty(string(arguments["workspace"])) ?? context.workspace + context.repository = nonempty(string(arguments["repository"])) ?? context.repository let response = try await SkillRuntimeResolver.resolve( - projectId: projectId, request: request, context: context, - tools: decode([RuntimeToolInventoryItem].self, arguments["available_tools"]) ?? [], db: db + projectId: projectId, + request: request, + event: event, + context: context, + currentSkillIds: decode([String].self, currentSkillArgument) ?? [], + tools: decode([RuntimeToolInventoryItem].self, toolsArgument) ?? [], + db: db ) - return SkillRuntimeJSON.encode(response) + return output(response) } - private static func discover(_ arguments: [String: String], db: Database, projectId: UUID) async throws -> String { - let query = nonempty(arguments["query"]) ?? "" - let response = try await SkillRuntimeResolver.resolve( - projectId: projectId, request: query, event: nonempty(arguments["event"]), - context: decode(RuntimeContext.self, arguments["context"]) ?? .init(), - currentSkillIds: decode([String].self, arguments["current_skill_ids"]) ?? [], - tools: decode([RuntimeToolInventoryItem].self, arguments["available_tools"]) ?? [], db: db + private static func legacyDiscover( + _ arguments: [String: JSONValue], + db: Database, + projectId: UUID + ) async throws -> ToolHandlerOutput { + var normalized = arguments + normalized["request"] = .string( + nonempty(string(arguments["query"])) + ?? "Discover the project skills relevant to the current task" + ) + return try await resolve(normalized, db: db, projectId: projectId) + } + + private static func getSkill( + _ arguments: [String: JSONValue], + db: Database, + projectId: UUID + ) async throws -> ToolHandlerOutput { + guard let skillId = boundedString(arguments["skill_id"], field: "skill_id", max: 128) else { + throw Abort(.badRequest, reason: "skill_id is required") + } + let version = try optionalBoundedString(arguments["version"], field: "version", max: 512) + let (row, document) = try await SkillPackageResourceService.activeCompiledSkill( + projectId: projectId, skillId: skillId, version: version, db: db + ) + if let rawPath = try optionalBoundedString(arguments["path"], field: "path", max: 1_024) { + let path = try SkillPackageResourceService.normalize(relativePath: rawPath) + let file = try await SkillPackageResourceService.file(compiled: row, path: path, db: db) + let resourceUri = SkillPackageResourceService.uri( + skillId: skillId, + path: path, + version: document.version + ) + let text = String(data: file.content, encoding: .utf8) + let response = SkillFileResponse( + kind: "file", id: skillId, version: document.version, path: path, + checksum: file.checksum, mediaType: file.contentType ?? "application/octet-stream", + byteCount: file.byteCount, resourceUri: resourceUri, text: text, + blob: text == nil ? file.content.base64EncodedString() : nil, + encoding: text == nil ? "base64" : "utf-8", source: document.source + ) + let link = MCPToolContent.resourceLink(MCPToolResourceLink( + uri: resourceUri, name: path, title: path, + description: "Package file for \(skillId)@\(document.version)", + mimeType: file.contentType ?? "application/octet-stream", size: file.byteCount + )) + return output(response, additionalContent: [link]) + } + let files = try await SkillPackageResourceService.files( + for: row, + skillId: skillId, + version: document.version, + db: db + ) + let response = CanonicalSkillResponse( + kind: "skill", id: document.id, name: document.name, description: document.description, + instructions: document.instructions, version: document.version, checksum: document.source.checksum, + mediaType: "text/markdown", + resourceUri: SkillPackageResourceService.uri(skillId: skillId, version: document.version), + source: document.source, files: files ) - return SkillRuntimeJSON.encode(response) + let links = files.map { file in + MCPToolContent.resourceLink(MCPToolResourceLink( + uri: file.resourceUri, name: file.path, title: file.path, + description: "Package file for \(skillId)@\(document.version)", + mimeType: file.mediaType, size: file.byteCount + )) + } + return output(response, additionalContent: links) } - private static func getSkill(_ arguments: [String: String], db: Database, projectId: UUID) async throws -> String { - guard let skillId = nonempty(arguments["skill_id"]), - let releaseId = try await MCPCatalogService.activeReleaseId(projectId: projectId, db: db) else { - throw Abort(.badRequest, reason: "skill_id is required and the project must have an active release") + private static func legacyListCapabilities( + _ arguments: [String: JSONValue], + db: Database, + projectId: UUID + ) async throws -> ToolHandlerOutput { + let skillId = nonempty(string(arguments["skill_id"])) + var normalized = arguments + normalized["request"] = .string(skillId ?? "List available skill capabilities") + if let skillId { + normalized["current_skill_ids"] = .array([.string(skillId)]) } - var query = CompiledSkill.query(on: db).filter(\.$release.$id == releaseId).filter(\.$skillId == skillId) - if let version = nonempty(arguments["version"]) { query = query.filter(\.$version == version) } - guard let row = try await query.first(), - let document = SkillRuntimeJSON.decode(CompiledSkillDocument.self, from: row.canonicalJson) else { - throw Abort(.notFound, reason: "Compiled skill not found") + let resolved = try await resolve(normalized, db: db, projectId: projectId) + guard case .object(let response) = resolved.structuredContent else { return resolved } + let bindings = response["capabilityBindings"] ?? .array([]) + var capabilities: Set = [] + if case .array(let values) = bindings { + for value in values { + if case .object(let binding) = value, + case .string(let capability)? = binding["capability"] { + capabilities.insert(capability) + } + } } - return SkillRuntimeJSON.encode(document) + let legacy = JSONValue.object([ + "schemaVersion": .integer(1), + "knownCapabilities": .array(capabilities.sorted().map(JSONValue.string)), + "bindings": bindings, + "unresolvedRequirements": response["missingRequirements"] ?? .array([]), + ]) + return ToolHandlerOutput(text: SkillRuntimeJSON.encode(legacy), structuredContent: legacy) } - private static func listCapabilities(_ arguments: [String: String], db: Database, projectId: UUID) async throws -> String { - let tools = decode([RuntimeToolInventoryItem].self, arguments["available_tools"]) ?? [] - let skillId = nonempty(arguments["skill_id"]) - let response = try await SkillRuntimeResolver.resolve( - projectId: projectId, request: skillId ?? "", currentSkillIds: skillId.map { [$0] } ?? [], tools: tools, db: db + private static func legacyCatalog( + _ arguments: [String: JSONValue], + db: Database, + projectId: UUID + ) async throws -> ToolHandlerOutput { + let mode = nonempty(string(arguments["mode"]))?.lowercased() + if mode == "skill" || nonempty(string(arguments["skill"])) != nil { + var normalized = arguments + normalized["skill_id"] = .string(normalizedSkillId(string(arguments["skill"]) ?? "")) + let skill = try await getSkill(normalized, db: db, projectId: projectId) + guard case .object(let document) = skill.structuredContent else { return skill } + let name = document["name"]?.stringValue ?? document["id"]?.stringValue ?? "Skill" + let instructions = document["instructions"]?.stringValue ?? skill.text + return ToolHandlerOutput( + text: "# \(name)\n\n\(instructions)", + structuredContent: skill.structuredContent + ) + } + + var normalized = arguments + let request = nonempty(string(arguments["task"])) ?? "List the project skills relevant to the current task" + normalized["request"] = .string(request) + let resolved = try await resolve(normalized, db: db, projectId: projectId) + let limited = mode == "route" || nonempty(string(arguments["task"])) != nil + ? legacyLimitedResolution(resolved, rawLimit: arguments["limit"]) + : resolved + let title = mode == "route" || nonempty(string(arguments["task"])) != nil + ? "# MCP catalog route" + : "# MCP catalog" + return ToolHandlerOutput( + text: "\(title)\n\nResolved by `resolve_context`.\n\n```json\n\(limited.text)\n```", + structuredContent: limited.structuredContent ) - struct Payload: Codable { var schemaVersion = 1; var knownCapabilities: [String]; var bindings: [CapabilityBindingResult]; var unresolvedRequirements: [String] } - return SkillRuntimeJSON.encode(Payload( - knownCapabilities: Array(Set(response.capabilityBindings.map(\.capability))).sorted(), - bindings: response.capabilityBindings, unresolvedRequirements: response.missingRequirements - )) } - private static func reportFeedback(_ arguments: [String: String], db: Database, projectId: UUID) async throws -> String { + private static func reportFeedback( + _ arguments: [String: JSONValue], + db: Database, + projectId: UUID + ) async throws -> ToolHandlerOutput { + struct PersistedFeedback: Sendable { + let id: UUID + let draft: [String: String] + let issueCreationEnabled: Bool + } let categories = Set(["missing_guidance", "ambiguous_instruction", "incorrect_instruction", "conflict", "missing_capability", "poor_discovery", "outdated_content", "other"]) - guard let skillId = nonempty(arguments["skill_id"]), let version = nonempty(arguments["version"]), - let category = nonempty(arguments["category"]), categories.contains(category), - let summary = nonempty(arguments["summary"]) else { throw Abort(.badRequest, reason: "skill_id, version, valid category, and summary are required") } - guard let releaseId = try await MCPCatalogService.activeReleaseId(projectId: projectId, db: db), - let row = try await CompiledSkill.query(on: db).filter(\.$release.$id == releaseId) - .filter(\.$skillId == skillId).filter(\.$version == version).first(), - let document = SkillRuntimeJSON.decode(CompiledSkillDocument.self, from: row.canonicalJson) else { - throw Abort(.notFound, reason: "The observed skill version is not active in this project") - } - let draft: [String: String] = [ - "title": "[Skill feedback] \(skillId): \(summary)", - "body": "Skill: \(skillId)@\(version)\nSource: \(document.source.path)\nCategory: \(category)\n\nSummary: \(summary)\n\nEvidence: \(nonempty(arguments["evidence"]) ?? "Not supplied")\n\nSuggested change: \(nonempty(arguments["suggested_change"]) ?? "Not supplied")" - ] - let record = SkillFeedbackRecord(); record.$project.id = projectId; record.skillId = skillId - record.skillVersion = version; record.sourcePath = document.source.path; record.sourceRevision = document.source.revision - record.category = category; record.summary = summary; record.evidence = nonempty(arguments["evidence"]) - record.suggestedChange = nonempty(arguments["suggested_change"]); record.issueDraftJson = SkillRuntimeJSON.encode(draft) - try await record.save(on: db) - let settings = try await ProjectRuntimeSettings.query(on: db).filter(\.$project.$id == projectId).first() - let requested = nonempty(arguments["create_issue"])?.lowercased() == "true" + guard let skillId = boundedString(arguments["skill_id"], field: "skill_id", max: 128), + let version = boundedString(arguments["version"], field: "version", max: 512), + let category = boundedString(arguments["category"], field: "category", max: 64), categories.contains(category), + let summary = boundedString(arguments["summary"], field: "summary", max: 2_000), + let evidence = boundedString(arguments["evidence"], field: "evidence", max: 8_000) else { + throw Abort(.badRequest, reason: "skill_id, version, valid category, summary, and evidence are required strings within their limits") + } + let suggestedChange = try optionalBoundedString(arguments["suggested_change"], field: "suggested_change", max: 8_000) + let persisted = try await db.transaction { transaction -> PersistedFeedback in + guard let releaseId = try await MCPCatalogService.activeReleaseId(projectId: projectId, db: transaction), + let row = try await CompiledSkill.query(on: transaction).filter(\.$release.$id == releaseId) + .filter(\.$skillId == skillId).filter(\.$version == version).first(), + let document = SkillRuntimeJSON.decode(CompiledSkillDocument.self, from: row.canonicalJson) else { + throw Abort(.notFound, reason: "The observed skill version is not active in this project") + } + let settings = try await ProjectRuntimeSettings.query(on: transaction) + .filter(\.$project.$id == projectId) + .first() + let draft: [String: String] = [ + "title": "[Skill feedback] \(skillId): \(summary)", + "body": "Skill: \(skillId)@\(version)\nSource: \(document.source.path)\nCategory: \(category)\n\nSummary: \(summary)\n\nEvidence: \(evidence)\n\nSuggested change: \(suggestedChange ?? "Not supplied")" + ] + let record = SkillFeedbackRecord(); record.$project.id = projectId; record.skillId = skillId + record.skillVersion = version; record.sourcePath = document.source.path; record.sourceRevision = document.source.revision + record.category = category; record.summary = summary; record.evidence = evidence + record.suggestedChange = suggestedChange; record.issueDraftJson = SkillRuntimeJSON.encode(draft) + try await record.save(on: transaction) + guard let feedbackId = record.id else { throw Abort(.internalServerError, reason: "Feedback record has no id") } + return PersistedFeedback( + id: feedbackId, + draft: draft, + issueCreationEnabled: settings?.feedbackIssueCreationEnabled == true + ) + } + let requested = bool(arguments["create_issue"]) ?? false struct FeedbackResponse: Codable { var schemaVersion = 1; var feedbackId: UUID; var effectStatus: String; var issueDraft: [String: String]; var creationAuthorized: Bool; var message: String } - return SkillRuntimeJSON.encode(FeedbackResponse( - feedbackId: record.id!, effectStatus: "draft", issueDraft: draft, - creationAuthorized: requested && settings?.feedbackIssueCreationEnabled == true, - message: requested && settings?.feedbackIssueCreationEnabled == true + return output(FeedbackResponse( + feedbackId: persisted.id, effectStatus: "draft", issueDraft: persisted.draft, + creationAuthorized: requested && persisted.issueCreationEnabled, + message: requested && persisted.issueCreationEnabled ? "Issue creation is authorized, but the harness must execute the returned draft through its bound issue.create tool." : "Feedback was stored and no external side effect occurred." )) @@ -101,5 +287,88 @@ enum SkillRuntimeToolHandlers { guard let value else { return nil }; let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } - private static func decode(_ type: T.Type, _ value: String?) -> T? { SkillRuntimeJSON.decode(type, from: value) } + + private static func normalizedSkillId(_ raw: String) -> String { + let prefix = "ctx://skill/" + let value = raw.hasPrefix(prefix) ? String(raw.dropFirst(prefix.count)) : raw + return value.removingPercentEncoding ?? value + } + + private static func string(_ value: JSONValue?) -> String? { + value?.stringValue + } + + private static func boundedString(_ value: JSONValue?, field: String, max: Int) -> String? { + guard case .string(let raw) = value else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= max else { return nil } + return trimmed + } + + private static func optionalBoundedString(_ value: JSONValue?, field: String, max: Int) throws -> String? { + guard let value else { return nil } + guard let result = boundedString(value, field: field, max: max) else { + throw Abort(.badRequest, reason: "\(field) must be a nonempty string no longer than \(max) characters") + } + return result + } + + private static func bool(_ value: JSONValue?) -> Bool? { + switch value { + case .bool(let value): return value + case .string(let value): return Bool(value) + default: return nil + } + } + + private static func decode(_ type: T.Type, _ value: JSONValue?) -> T? { + guard let value else { return nil } + if case .string(let legacyJSON) = value { + return SkillRuntimeJSON.decode(type, from: legacyJSON) + } + guard let data = try? JSONEncoder().encode(value) else { return nil } + return try? JSONDecoder().decode(type, from: data) + } + + private static func legacyLimitedResolution( + _ output: ToolHandlerOutput, + rawLimit: JSONValue? + ) -> ToolHandlerOutput { + let raw: Int? + switch rawLimit { + case .integer(let value): raw = value + case .string(let value): raw = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + default: raw = nil + } + let limit = min(max(raw ?? 5, 1), 20) + guard case .object(var response) = output.structuredContent else { return output } + var remaining = limit + var selectedSkillIds = Set() + for key in ["activeSkills", "suggestedTaskSkills"] { + guard case .array(let values) = response[key] else { continue } + let selected = Array(values.prefix(remaining)) + response[key] = .array(selected) + for value in selected { + if case .object(let skill) = value, case .string(let id)? = skill["id"] { + selectedSkillIds.insert(id) + } + } + remaining -= selected.count + } + if case .array(let actions) = response["nextActions"] { + response["nextActions"] = .array(actions.filter { value in + guard case .object(let action) = value, + case .string(let skillId)? = action["skillId"] else { return true } + return selectedSkillIds.contains(skillId) + }) + } + let structured = JSONValue.object(response) + return ToolHandlerOutput(text: SkillRuntimeJSON.encode(structured), structuredContent: structured) + } + + private static func output(_ value: T, additionalContent: [MCPToolContent] = []) -> ToolHandlerOutput { + let text = SkillRuntimeJSON.encode(value) + let structured = SkillRuntimeJSON.decode(JSONValue.self, from: text) + return ToolHandlerOutput(text: text, structuredContent: structured, additionalContent: additionalContent) + } } diff --git a/services/mcp-gateway/Sources/App/Services/SkillMetadataWritebackService.swift b/services/mcp-gateway/Sources/App/Services/SkillMetadataWritebackService.swift index e505b3e..b3d4f78 100644 --- a/services/mcp-gateway/Sources/App/Services/SkillMetadataWritebackService.swift +++ b/services/mcp-gateway/Sources/App/Services/SkillMetadataWritebackService.swift @@ -1,6 +1,7 @@ import Fluent import Foundation import Vapor +import Yams enum SkillMetadataWritebackService { struct Result: Content { let pull_request_url: String; let branch: String; let source_path: String } @@ -8,7 +9,7 @@ enum SkillMetadataWritebackService { static func createDraftPullRequest(compiled: CompiledSkill, project: Project, app: Application, db: Database) async throws -> Result { guard let document = SkillRuntimeJSON.decode(CompiledSkillDocument.self, from: compiled.canonicalJson), !document.validation.clarificationRequired else { throw Abort(.conflict, reason: "Resolve all runtime clarification questions before write-back") } - let path = document.source.path + let path = ".mycontext/skills.yaml" let allowedPath = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "/-_.")) guard !path.hasPrefix("/"), !path.split(separator: "/").contains(".."), path.unicodeScalars.allSatisfy(allowedPath.contains) else { throw Abort(.badRequest, reason: "Invalid source path") } @@ -35,17 +36,34 @@ enum SkillMetadataWritebackService { } guard createRef.status == .created else { throw githubError(createRef, action: "create the metadata branch") } - struct FileResponse: Content { let sha: String } + struct FileResponse: Content { let sha: String; let content: String?; let encoding: String? } let encodedPath = path.split(separator: "/").map { String($0).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String($0) }.joined(separator: "/") let fileResponse = try await app.client.get(URI(string: "\(api)/contents/\(encodedPath)?ref=\(base)"), headers: headers) - guard fileResponse.status == .ok else { throw githubError(fileResponse, action: "read the skill source") } - let fileSha = try fileResponse.content.decode(FileResponse.self).sha - let content = frontmatter(document) + "\n" + document.instructions.trimmingCharacters(in: .whitespacesAndNewlines) + "\n" - struct UpdateFile: Content { let message: String; let content: String; let branch: String; let sha: String } + guard fileResponse.status == .ok || fileResponse.status == .notFound else { + throw githubError(fileResponse, action: "read the skill policy sidecar") + } + let existing = fileResponse.status == .ok ? try fileResponse.content.decode(FileResponse.self) : nil + let currentYaml: String? + if let existing { + guard existing.encoding == nil || existing.encoding == "base64", + let encoded = existing.content else { + throw Abort(.conflict, reason: "Existing skill policy sidecar could not be decoded safely") + } + let compact = encoded.replacingOccurrences(of: "\n", with: "") + guard let data = Data(base64Encoded: compact), + let decoded = String(data: data, encoding: .utf8) else { + throw Abort(.conflict, reason: "Existing skill policy sidecar is not valid base64 UTF-8") + } + currentYaml = decoded + } else { + currentYaml = nil + } + let content = try mergedSidecar(existing: currentYaml, document: document, exposure: compiled.exposureType) + struct UpdateFile: Content { let message: String; let content: String; let branch: String; let sha: String? } let update = try await app.client.put(URI(string: "\(api)/contents/\(encodedPath)"), headers: headers) { request in try request.content.encode(UpdateFile( - message: "chore: add portable runtime metadata for \(document.id)", - content: Data(content.utf8).base64EncodedString(), branch: branch, sha: fileSha + message: "chore: update portable runtime policy for \(document.id)", + content: Data(content.utf8).base64EncodedString(), branch: branch, sha: existing?.sha )) } guard update.status == .ok || update.status == .created else { throw githubError(update, action: "write skill metadata") } @@ -54,8 +72,8 @@ enum SkillMetadataWritebackService { struct PullResponse: Content { let html_url: String } let pull = try await app.client.post(URI(string: "\(api)/pulls"), headers: headers) { request in try request.content.encode(CreatePull( - title: "Add portable runtime metadata for \(document.id)", head: branch, base: base, - body: "Generated from validated MyContextProtocol deployment metadata. The database override remains active until this metadata is merged and synced.", draft: true + title: "Update portable runtime policy for \(document.id)", head: branch, base: base, + body: "Updates `.mycontext/skills.yaml` from validated MyContextProtocol deployment metadata. `SKILL.md` remains the package's authored source and is not reconstructed.", draft: true )) } guard pull.status == .created else { throw githubError(pull, action: "open the draft pull request") } @@ -77,16 +95,72 @@ enum SkillMetadataWritebackService { return oauth } - private static func frontmatter(_ document: CompiledSkillDocument) -> String { - func quoted(_ value: String) -> String { String(data: try! JSONEncoder().encode(value), encoding: .utf8)! } - func list(_ values: [String]) -> String { "[" + values.map(quoted).joined(separator: ", ") + "]" } - var lines = ["---", "name: \(quoted(document.id))", "description: \(quoted(document.description))", "kind: \(document.kind.rawValue)", "scope: \(document.scope.rawValue)", "enforcement: \(document.enforcement.rawValue)", "priority: \(document.priority)", "version: \(quoted(document.version))", "activation:", " mode: \(document.activation.mode.rawValue)", " intents: \(list(document.activation.intents))", " events: \(list(document.activation.events))", " tags: \(list(document.activation.tags))", " examples: \(list(document.activation.examples))"] - if !document.requires.isEmpty { - lines.append("requires:") - for requirement in document.requires { lines.append(" - capability: \(quoted(requirement.capability))"); lines.append(" required: \(requirement.required)"); lines.append(" on_missing: \(requirement.onMissing.rawValue)") } + static func mergedSidecar(existing: String?, document: CompiledSkillDocument, exposure: String = "resource") throws -> String { + var root = (try existing.flatMap { try load(yaml: $0) } as? [String: Any]) ?? [:] + var skills = root["skills"] as? [String: Any] ?? [:] + let metadata = SkillRuntimeOverridePatch( + exposure: exposure, + kind: document.kind, + scope: document.scope, + activation: document.activation, + enforcement: document.enforcement, + priority: document.priority, + requires: document.requires, + avoidWhen: document.avoidWhen, + conflictsWith: document.conflictsWith, + version: document.version, + lifecycle: document.lifecycle + ) + let policy = SkillSourcePolicy(baseChecksum: document.source.checksum, metadata: metadata) + let encoded = try JSONEncoder().encode(policy) + let object = try JSONSerialization.jsonObject(with: encoded) + guard let generated = yamlCompatible(object) as? [String: Any] else { + throw Abort(.internalServerError, reason: "Generated skill policy was not an object") + } + let existingSkill = skills[document.id] as? [String: Any] ?? [:] + skills[document.id] = merge(existing: existingSkill, generated: generated) + root["version"] = root["version"] ?? 1 + root["skills"] = skills + return try dump(object: root, sortKeys: true) + "\n" + } + + private static func yamlCompatible(_ value: Any) -> Any { + if let string = value as? NSString { return string as String } + if let number = value as? NSNumber { + if String(cString: number.objCType) == "c" { return number.boolValue } + let double = number.doubleValue + return double.rounded() == double ? number.intValue : double + } + if let dictionary = value as? [String: Any] { + return dictionary.mapValues(yamlCompatible) + } + if let array = value as? [Any] { + return array.map(yamlCompatible) + } + if let dictionary = value as? NSDictionary { + var result: [String: Any] = [:] + for (key, item) in dictionary { + if let key = key as? String { result[key] = yamlCompatible(item) } + } + return result + } + if let array = value as? NSArray { + return array.map(yamlCompatible) + } + return value + } + + private static func merge(existing: [String: Any], generated: [String: Any]) -> [String: Any] { + var result = existing + for (key, value) in generated { + if let generatedObject = value as? [String: Any], + let existingObject = result[key] as? [String: Any] { + result[key] = merge(existing: existingObject, generated: generatedObject) + } else { + result[key] = value + } } - lines.append("conflictsWith: \(list(document.conflictsWith))"); lines.append("---") - return lines.joined(separator: "\n") + return result } private static func githubError(_ response: ClientResponse, action: String) -> Abort { diff --git a/services/mcp-gateway/Sources/App/Sync/Compiler.swift b/services/mcp-gateway/Sources/App/Sync/Compiler.swift index 43a4ed2..f95ed46 100644 --- a/services/mcp-gateway/Sources/App/Sync/Compiler.swift +++ b/services/mcp-gateway/Sources/App/Sync/Compiler.swift @@ -8,7 +8,8 @@ struct Compiler { /// Compiles skill packages into compiled_skills, routing_rules, and capability_defs. func compile( releaseId: UUID, - skills: [(parsed: ParsedSkill, package: SkillPackage)] + skills: [(parsed: ParsedSkill, package: SkillPackage)], + sourcePolicies: [String: SkillSourcePolicyState] = [:] ) async throws { let release = try await Release.find(releaseId, on: db) let projectId = release?.$project.id @@ -16,7 +17,38 @@ struct Compiler { try await RepoConnection.query(on: db).filter(\.$project.$id == projectId).first() } else { nil } for (parsed, package) in skills { - let exposureType = SkillInference.inferExposureType(from: parsed) + let sourcePolicyState = sourcePolicies[package.name] + let sourcePolicyStale = sourcePolicyState?.policy.baseChecksum.map { $0 != parsed.hash } ?? false + let sourcePolicy = sourcePolicyStale ? nil : sourcePolicyState?.policy + let overrideRow: SkillRuntimeOverride? + if let projectId { + let candidates = try await SkillRuntimeOverride.query(on: db) + .filter(\.$project.$id == projectId) + .filter(\.$skillId == package.name) + .all() + overrideRow = Self.selectOverride( + from: candidates, + repoConnectionId: connection?.id, + preferredScope: sourcePolicy?.metadata.scope ?? parsed.scope ?? .task + ) + } else { + overrideRow = nil + } + let overrideBaseChecksum = overrideRow?.baseChecksum ?? overrideRow?.sourceChecksum + let overrideStale = overrideBaseChecksum.map { $0 != parsed.hash } ?? false + if let overrideRow, overrideRow.isStale != overrideStale { + overrideRow.isStale = overrideStale + try await overrideRow.save(on: db) + } + let overridePatch = overrideStale ? nil : SkillRuntimeJSON.decode(SkillRuntimeOverridePatch.self, from: overrideRow?.metadataJson) + // Standard Agent Skills are guidance resources unless they explicitly opt into an + // executable exposure. This avoids manufacturing callable tools from documentation. + let exposureType = Self.exposureType( + for: parsed, + policyExposure: overridePatch?.exposure ?? sourcePolicy?.metadata.exposure + ) + let effectiveUseWhen = overridePatch?.activation?.intents ?? sourcePolicy?.metadata.activation?.intents ?? parsed.useWhen + let effectiveAvoidWhen = overridePatch?.avoidWhen ?? sourcePolicy?.metadata.avoidWhen ?? parsed.avoidWhen let sideEffectLevel = SkillInference.inferSideEffectLevel(from: parsed) let riskLevel = SkillInference.inferRiskLevel(from: parsed) let repoSpecific = SkillInference.inferRepoSpecific(from: parsed) @@ -41,8 +73,8 @@ struct Compiler { case "resource": schemaJson = CapabilitySchemaBuilder.resourceMetaJson( skillName: package.name, - useWhen: parsed.useWhen, - avoidWhen: parsed.avoidWhen, + useWhen: effectiveUseWhen, + avoidWhen: effectiveAvoidWhen, failureModes: parsed.failureModes, invokeFirst: parsed.invokeFirst ) @@ -55,7 +87,12 @@ struct Compiler { ) } - let routingHints = RoutingHints.from(parsed: parsed) + let routingHints = RoutingHints( + useWhen: effectiveUseWhen, + avoidWhen: effectiveAvoidWhen, + failureModes: parsed.failureModes, + invokeFirst: parsed.invokeFirst + ) let metadataTier = McpMetadataHealth.metadataOnlyTier( exposureType: exposureType, yamlFrontmatterPresent: parsed.hadYamlFrontmatter, @@ -81,19 +118,12 @@ struct Compiler { status: status, yamlFrontmatterPresent: parsed.hadYamlFrontmatter ) - let overridePatch: SkillRuntimeOverridePatch? = if let projectId, - let row = try await SkillRuntimeOverride.query(on: db) - .filter(\.$project.$id == projectId) - .filter(\.$skillId == package.name) - .sort(\.$scope, .ascending) - .first() { - SkillRuntimeJSON.decode(SkillRuntimeOverridePatch.self, from: row.metadataJson) - } else { nil } let canonical = SkillCanonicalCompiler.compile( parsed: parsed, package: package, repository: connection.map { "\($0.repoOwner)/\($0.repoName)" }, revision: release?.commitSha == "pending" ? parsed.hash : release?.commitSha, + sourcePolicy: sourcePolicy, override: overridePatch ) compiledSkill.skillId = canonical.document.id @@ -104,13 +134,16 @@ struct Compiler { compiledSkill.priority = canonical.document.priority compiledSkill.version = canonical.document.version compiledSkill.sourceChecksum = canonical.document.source.checksum + compiledSkill.sourcePolicyJson = sourcePolicyState?.rawJson + compiledSkill.sourcePolicyBaseChecksum = sourcePolicyState?.policy.baseChecksum + compiledSkill.sourcePolicyStale = sourcePolicyStale compiledSkill.canonicalJson = SkillRuntimeJSON.encode(canonical.document) compiledSkill.clarificationJson = SkillRuntimeJSON.encode(canonical.questions) compiledSkill.clarificationRequired = canonical.document.validation.clarificationRequired try await compiledSkill.save(on: db) - let useWhenJson = parsed.useWhen.flatMap { (try? JSONEncoder().encode($0)).flatMap { String(data: $0, encoding: .utf8) } } - let avoidWhenJson = parsed.avoidWhen.flatMap { (try? JSONEncoder().encode($0)).flatMap { String(data: $0, encoding: .utf8) } } + let useWhenJson = effectiveUseWhen.flatMap { (try? JSONEncoder().encode($0)).flatMap { String(data: $0, encoding: .utf8) } } + let avoidWhenJson = effectiveAvoidWhen.flatMap { (try? JSONEncoder().encode($0)).flatMap { String(data: $0, encoding: .utf8) } } let failureModesJson = parsed.failureModes.flatMap { (try? JSONEncoder().encode($0)).flatMap { String(data: $0, encoding: .utf8) } } let rule = RoutingRule( compiledSkillId: compiledSkill.id!, @@ -131,6 +164,48 @@ struct Compiler { } } + static func exposureType(for parsed: ParsedSkill, policyExposure: String? = nil) -> String { + if let policyExposure { + let normalized = policyExposure.lowercased() + if ["tool", "resource", "prompt"].contains(normalized) { return normalized } + } + return parsed.exposeAs == nil ? "resource" : SkillInference.inferExposureType(from: parsed) + } + + /// Tenant overrides may be project-wide (`repo_connection_id = NULL`) or tied to the + /// repository currently being compiled. Ignore overrides owned by another repository, + /// prefer the current repository over the project fallback, then choose the override whose + /// declared scope matches the source policy. Remaining ties are stable across database plans. + static func selectOverride( + from rows: [SkillRuntimeOverride], + repoConnectionId: UUID?, + preferredScope: SkillScope + ) -> SkillRuntimeOverride? { + let eligible = rows.filter { row in + guard let candidateRepoId = row.$repoConnection.id else { return true } + return candidateRepoId == repoConnectionId + } + let scopeOrder: [String: Int] = [ + SkillScope.task.rawValue: 0, + SkillScope.repository.rawValue: 1, + SkillScope.workspace.rawValue: 2, + SkillScope.organization.rawValue: 3, + SkillScope.global.rawValue: 4 + ] + return eligible.sorted { lhs, rhs in + let lhsRepoRank = lhs.$repoConnection.id == repoConnectionId && repoConnectionId != nil ? 0 : 1 + let rhsRepoRank = rhs.$repoConnection.id == repoConnectionId && repoConnectionId != nil ? 0 : 1 + if lhsRepoRank != rhsRepoRank { return lhsRepoRank < rhsRepoRank } + let lhsScopeRank = lhs.scope == preferredScope.rawValue ? -1 : (scopeOrder[lhs.scope] ?? 99) + let rhsScopeRank = rhs.scope == preferredScope.rawValue ? -1 : (scopeOrder[rhs.scope] ?? 99) + if lhsScopeRank != rhsScopeRank { return lhsScopeRank < rhsScopeRank } + let lhsUpdated = lhs.updatedAt ?? .distantPast + let rhsUpdated = rhs.updatedAt ?? .distantPast + if lhsUpdated != rhsUpdated { return lhsUpdated > rhsUpdated } + return (lhs.id?.uuidString ?? "") < (rhs.id?.uuidString ?? "") + }.first + } + /// Recomputes `schema_json` when a compiled skill's exposure type is changed via the API. static func schemaJson( forCapabilityType capabilityType: String, diff --git a/services/mcp-gateway/Sources/App/Sync/Pipeline.swift b/services/mcp-gateway/Sources/App/Sync/Pipeline.swift index 6fe7038..0d486db 100644 --- a/services/mcp-gateway/Sources/App/Sync/Pipeline.swift +++ b/services/mcp-gateway/Sources/App/Sync/Pipeline.swift @@ -1,5 +1,6 @@ import Fluent import Vapor +import Crypto struct SyncPipeline { let db: Database @@ -89,6 +90,7 @@ struct SyncPipeline { let repoRoot = try fetcher.resolveRepositoryRoot(extractPath: extractPath) let basePath = repoRoot.path let skillFiles = fetcher.findSkillFiles(in: repoRoot) + let sourcePolicies = try SkillCanonicalCompiler.sourcePolicies(repoRoot: repoRoot) var allValid = true var errorSummary: String? @@ -112,6 +114,11 @@ struct SyncPipeline { validationStatus: validationStatus ) try await skillPackage.save(on: db) + try await Self.persistPackageFiles( + package: skillPackage, + skillDirectory: fileURL.deletingLastPathComponent(), + db: db + ) parsedSkills.append((skill, skillPackage)) if !report.isValid { @@ -143,8 +150,23 @@ struct SyncPipeline { } } - let compiler = Compiler(db: db) - try await compiler.compile(releaseId: release.id!, skills: parsedSkills) + let duplicateErrors = Validator.duplicateSkillIDErrors(parsedSkills.map { $0.0 }) + if !duplicateErrors.isEmpty { + allValid = false + validationErrors.append(contentsOf: duplicateErrors.map { ["path": $0.path, "message": $0.message] }) + errorSummary = (errorSummary.map { $0 + "\n" } ?? "") + + duplicateErrors.map { "\($0.path): \($0.message)" }.joined(separator: "\n") + } + + let skillsToCompile = parsedSkills + try await db.transaction { transaction in + let compiler = Compiler(db: transaction) + try await compiler.compile( + releaseId: release.id!, + skills: skillsToCompile, + sourcePolicies: sourcePolicies + ) + } let bodyChangeCount = try await ReleaseMetadataCarryForward.apply( db: db, @@ -195,6 +217,104 @@ enum PipelineError: Error { } extension SyncPipeline { + static let maxPackageFileCount = 128 + static let maxPackageFileBytes = 256 * 1024 + static let maxPackageBytes = 2 * 1024 * 1024 + + private struct PendingPackageFile: Sendable { + let path: String + let content: Data + let contentType: String? + let checksum: String + } + + static func persistPackageFiles(package: SkillPackage, skillDirectory: URL, db: Database) async throws { + guard let packageId = package.id else { return } + let root = skillDirectory.standardizedFileURL + let files = try packageFiles(skillDirectory: root) + try await db.transaction { transaction in + for file in files { + let row = SkillPackageFile() + row.$skillPackage.id = packageId + row.path = file.path + row.content = file.content + row.contentType = file.contentType + row.byteCount = file.content.count + row.checksum = file.checksum + try await row.save(on: transaction) + } + } + } + + private static func packageFiles(skillDirectory root: URL) throws -> [PendingPackageFile] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) else { return [] } + var pending: [PendingPackageFile] = [] + var totalBytes = 0 + while let fileURL = enumerator.nextObject() as? URL { + guard fileURL.lastPathComponent != "SKILL.md" else { continue } + let values = try fileURL.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey] + ) + if values.isSymbolicLink == true { throw PackageFileIngestionError.unsafeEntry } + if values.isDirectory == true { + let nestedSkill = fileURL.appendingPathComponent("SKILL.md") + if FileManager.default.fileExists(atPath: nestedSkill.path) { + enumerator.skipDescendants() + } + continue + } + guard values.isRegularFile == true else { continue } + let resolved = fileURL.resolvingSymlinksInPath().standardizedFileURL + let relative = try safePackageRelativePath(fileURL: resolved, root: root) + let size = values.fileSize ?? 0 + guard size >= 0, size <= maxPackageFileBytes, + pending.count < maxPackageFileCount, totalBytes + size <= maxPackageBytes else { + throw PackageFileIngestionError.boundsExceeded + } + let data = try Data(contentsOf: resolved, options: [.mappedIfSafe]) + guard data.count == size else { throw PackageFileIngestionError.fileChangedDuringRead } + pending.append(.init( + path: relative, + content: data, + contentType: contentType(for: relative), + checksum: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + )) + totalBytes += data.count + } + return pending + } + + static func safePackageRelativePath(fileURL: URL, root: URL) throws -> String { + let normalizedRoot = root.standardizedFileURL + let normalizedFile = fileURL.standardizedFileURL + guard normalizedFile.path.hasPrefix(normalizedRoot.path + "/") else { + throw PackageFileIngestionError.unsafeEntry + } + let relative = String(normalizedFile.path.dropFirst(normalizedRoot.path.count + 1)) + guard !relative.isEmpty, !relative.split(separator: "/").contains("..") else { + throw PackageFileIngestionError.unsafeEntry + } + return relative + } + + private static func contentType(for path: String) -> String? { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "md": return "text/markdown" + case "txt": return "text/plain" + case "json": return "application/json" + case "yaml", "yml": return "application/yaml" + case "sh": return "text/x-shellscript" + case "py": return "text/x-python" + case "js", "mjs": return "text/javascript" + case "ts": return "text/typescript" + default: return nil + } + } + fileprivate static func relativeRepoPath(fileURL: URL, repoRootPath: String) -> String { let p = fileURL.path let prefix = repoRootPath.hasSuffix("/") ? repoRootPath : repoRootPath + "/" @@ -204,3 +324,9 @@ extension SyncPipeline { return fileURL.lastPathComponent } } + +enum PackageFileIngestionError: Error, Equatable { + case boundsExceeded + case fileChangedDuringRead + case unsafeEntry +} diff --git a/services/mcp-gateway/Sources/App/Sync/Validator.swift b/services/mcp-gateway/Sources/App/Sync/Validator.swift index 9a69a15..f85afb3 100644 --- a/services/mcp-gateway/Sources/App/Sync/Validator.swift +++ b/services/mcp-gateway/Sources/App/Sync/Validator.swift @@ -14,7 +14,7 @@ struct ValidationError { struct Validator { static let maxFileSize = 1024 * 1024 - static let allowedNamePattern = try! NSRegularExpression(pattern: "^[a-z0-9][a-z0-9-]*$") + static let allowedNamePattern = try! NSRegularExpression(pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$") static func validate(_ skill: ParsedSkill) -> ValidationReport { var errors: [ValidationError] = [] @@ -58,6 +58,31 @@ struct Validator { )) } + if skill.hadYamlFrontmatter { + let folderName = URL(fileURLWithPath: skill.path).deletingLastPathComponent().lastPathComponent + if folderName.isEmpty || folderName != skill.name { + errors.append(ValidationError( + path: skill.path, + message: "name must exactly match the parent skill directory: expected \"\(folderName)\"", + line: nil + )) + } + let description = skill.description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if description.isEmpty { + errors.append(ValidationError(path: skill.path, message: "description cannot be empty", line: nil)) + } else if description.count > 1024 { + errors.append(ValidationError(path: skill.path, message: "description must be 1024 characters or less", line: nil)) + } + } + + if MCPConstants.isReservedRuntimeToolName(skill.name) { + errors.append(ValidationError( + path: skill.path, + message: "name is reserved by the MyContextProtocol runtime: \"\(skill.name)\"", + line: nil + )) + } + if skill.body.count > Self.maxFileSize { errors.append(ValidationError( path: skill.path, @@ -72,4 +97,14 @@ struct Validator { warnings: warnings ) } + + static func duplicateSkillIDErrors(_ skills: [ParsedSkill]) -> [ValidationError] { + let grouped = Dictionary(grouping: skills, by: \.name) + return grouped.keys.sorted().flatMap { name -> [ValidationError] in + guard let matches = grouped[name], matches.count > 1 else { return [] } + return matches.map { + ValidationError(path: $0.path, message: "duplicate skill id \"\(name)\" in this release", line: nil) + } + } + } } diff --git a/services/mcp-gateway/Sources/App/configure.swift b/services/mcp-gateway/Sources/App/configure.swift index 549bdbb..f198d3d 100644 --- a/services/mcp-gateway/Sources/App/configure.swift +++ b/services/mcp-gateway/Sources/App/configure.swift @@ -65,7 +65,7 @@ public func configure(_ app: Application) async throws { let corsConfig = CORSMiddleware.Configuration( allowedOrigin: allowedOrigin, allowedMethods: [.GET, .POST, .PUT, .OPTIONS, .DELETE, .PATCH], - allowedHeaders: [.accept, .authorization, .contentType, .origin, .xRequestedWith], + allowedHeaders: [.accept, .authorization, .contentType, .origin, .xRequestedWith, .init("MCP-Protocol-Version")], allowCredentials: true ) app.middleware.use(CORSMiddleware(configuration: corsConfig), at: .beginning) @@ -237,6 +237,7 @@ public func configure(_ app: Application) async throws { app.migrations.add(StripLegacySkillPrefixFromMcpWireNames()) app.migrations.add(AddStripeStatusCheckedAt()) app.migrations.add(AddPortableSkillRuntime()) + app.migrations.add(HardenPortableSkillRuntime()) try await app.autoMigrate() diff --git a/services/mcp-gateway/Tests/AppTests/JSONRPCRequestDecodeTests.swift b/services/mcp-gateway/Tests/AppTests/JSONRPCRequestDecodeTests.swift index ed74341..f311275 100644 --- a/services/mcp-gateway/Tests/AppTests/JSONRPCRequestDecodeTests.swift +++ b/services/mcp-gateway/Tests/AppTests/JSONRPCRequestDecodeTests.swift @@ -29,18 +29,29 @@ struct JSONRPCRequestDecodeTests { let json = #"{"name":"n","arguments":{"a":"1"}}"#.data(using: .utf8)! let p = try JSONDecoder().decode(JSONRPCParams.self, from: json) #expect(p.name == "n") - #expect(p.arguments == ["a": "1"]) + #expect(p.arguments == ["a": .string("1")]) + #expect(p.stringArguments == ["a": "1"]) #expect(p.uri == nil) } - @Test("JSONRPCParams nested arguments coerce numbers and bools") + @Test("JSONRPCParams preserves native nested arguments") func paramsNested() throws { - let json = #"{"arguments":{"s":"x","i":3,"b":true,"d":1.5}}"#.data(using: .utf8)! + let json = #"{"arguments":{"s":"x","i":3,"b":true,"d":1.5,"nil":null,"array":["a",2],"object":{"enabled":false}}}"#.data(using: .utf8)! let p = try JSONDecoder().decode(JSONRPCParams.self, from: json) - #expect(p.arguments?["s"] == "x") - #expect(p.arguments?["i"] == "3") - #expect(p.arguments?["b"] == "true") - #expect(p.arguments?["d"] == "1.5") + #expect(p.arguments?["s"] == .string("x")) + #expect(p.arguments?["i"] == .integer(3)) + #expect(p.arguments?["b"] == .bool(true)) + #expect(p.arguments?["d"] == .number(1.5)) + #expect(p.arguments?["nil"] == .null) + #expect(p.arguments?["array"] == .array([.string("a"), .integer(2)])) + #expect(p.arguments?["object"] == .object(["enabled": .bool(false)])) + #expect(p.stringArguments == ["s": "x", "i": "3", "b": "true", "d": "1.5"]) + + let roundTrip = try JSONDecoder().decode( + JSONRPCParams.self, + from: JSONEncoder().encode(p) + ) + #expect(roundTrip == p) } @Test("JSONRPCRequest full envelope") @@ -51,6 +62,14 @@ struct JSONRPCRequestDecodeTests { #expect(r.method == "tools/list") } + @Test("List cursors decode and round-trip") + func listCursor() throws { + let json = #"{"cursor":"opaque-page-2"}"#.data(using: .utf8)! + let params = try JSONDecoder().decode(JSONRPCParams.self, from: json) + #expect(params.cursor == "opaque-page-2") + #expect(try JSONDecoder().decode(JSONRPCParams.self, from: JSONEncoder().encode(params)) == params) + } + @Test("InputSchema fromCapabilitySchemaJson defaults on empty") func inputSchemaDefault() { let s = InputSchema.fromCapabilitySchemaJson(nil) diff --git a/services/mcp-gateway/Tests/AppTests/MCPAgentVisibilityTests.swift b/services/mcp-gateway/Tests/AppTests/MCPAgentVisibilityTests.swift index 9be204a..2616cad 100644 --- a/services/mcp-gateway/Tests/AppTests/MCPAgentVisibilityTests.swift +++ b/services/mcp-gateway/Tests/AppTests/MCPAgentVisibilityTests.swift @@ -1,15 +1,18 @@ import Foundation +import MCPServerKit import Testing @testable import App @Suite("MCP agent visibility") struct MCPAgentVisibilityTests { @Test func protocolVersionNegotiation() { - #expect(MCPProtocolVersion.negotiated(requested: "2025-06-18") == "2025-06-18") - #expect(MCPProtocolVersion.negotiated(requested: "2024-11-05") == "2024-11-05") - #expect(MCPProtocolVersion.negotiated(requested: "2099-01-01") == "2024-11-05") - #expect(MCPProtocolVersion.negotiated(requested: nil) == "2024-11-05") - #expect(MCPProtocolVersion.negotiated(requested: " ") == "2024-11-05") + #expect(MCPServerKit.MCPProtocolVersion.negotiated(requested: "2025-11-25") == "2025-11-25") + #expect(MCPServerKit.MCPProtocolVersion.negotiated(requested: "2025-03-26") == "2025-03-26") + #expect(MCPServerKit.MCPProtocolVersion.negotiated(requested: "2025-06-18") == "2025-06-18") + #expect(MCPServerKit.MCPProtocolVersion.negotiated(requested: "2024-11-05") == "2024-11-05") + #expect(MCPServerKit.MCPProtocolVersion.negotiated(requested: "2099-01-01") == "2025-11-25") + #expect(MCPServerKit.MCPProtocolVersion.negotiated(requested: nil) == "2025-11-25") + #expect(MCPServerKit.MCPProtocolVersion.negotiated(requested: " ") == "2025-11-25") } @Test func catalogRevisionBumps() { @@ -37,9 +40,86 @@ struct MCPAgentVisibilityTests { } @Test func jsonRpcParamsDecodesProtocolVersion() throws { - let json = #"{"protocolVersion":"2025-06-18","capabilities":{}}"#.data(using: .utf8)! + let json = #"{"protocolVersion":"2025-11-25","capabilities":{}}"#.data(using: .utf8)! let p = try JSONDecoder().decode(JSONRPCParams.self, from: json) - #expect(p.protocolVersion == "2025-06-18") + #expect(p.protocolVersion == "2025-11-25") + } + + @Test func initializeCopyAdvertisesCanonicalBootstrapTool() { + let copy = MCPAgentCopy.initializeInstructions(projectName: "Example", projectDashboardURL: nil) + #expect(copy.contains("`resolve_context`")) + #expect(copy.contains("`get_skill`")) + #expect(copy.contains("`report_skill_feedback`")) + #expect(!copy.contains("`mycontext_catalog`")) + #expect(!copy.contains("`discover_skills`")) + #expect(!copy.contains("`list_capabilities`")) + } + + @Test func publicAndReservedRuntimeToolNamesAreStable() { + #expect(MCPConstants.runtimeToolNames == ["resolve_context", "get_skill", "report_skill_feedback"]) + #expect(Set(MCPConstants.hiddenRuntimeToolAliases) == ["mycontext_catalog", "discover_skills", "list_capabilities"]) + #expect(MCPConstants.callableRuntimeToolNames.count == 6) + for name in MCPConstants.callableRuntimeToolNames { + #expect(MCPConstants.isReservedRuntimeToolName(name)) + } + } + + @Test func runtimeSchemasAreRequiredTypedAndStructured() { + let resolve = CapabilitySchemaBuilder.runtimeToolInputSchema(name: "resolve_context") + #expect(resolve.required == ["request"]) + #expect(resolve.additionalProperties == false) + #expect(resolve.properties?["available_tools"]?.type == "array") + #expect(resolve.properties?["available_tools"]?.items?.type == "object") + #expect(resolve.properties?["context"]?.type == "object") + + let feedback = CapabilitySchemaBuilder.runtimeToolInputSchema(name: "report_skill_feedback") + #expect(Set(feedback.required ?? []) == ["skill_id", "version", "category", "summary", "evidence"]) + #expect(feedback.properties?["category"]?.enumValues?.contains(.string("poor_discovery")) == true) + #expect(feedback.properties?["create_issue"]?.type == "boolean") + #expect(feedback.properties?["evidence"]?.maxLength == 8_000) + + for name in MCPConstants.runtimeToolNames { + let output = CapabilitySchemaBuilder.runtimeToolOutputSchema(name: name) + #expect(output.type == "object") + #expect(output.required?.isEmpty == false) + } + } + + @Test func paginationIsStableAndRejectsWrongContext() throws { + let values = ["a", "b", "c", "d", "e"] + let first = try MCPPaginator.page(values, cursor: nil, scope: "tools:release-a", pageSize: 2) + #expect(first.items == ["a", "b"]) + let second = try MCPPaginator.page(values, cursor: first.nextCursor, scope: "tools:release-a", pageSize: 2) + #expect(second.items == ["c", "d"]) + let final = try MCPPaginator.page(values, cursor: second.nextCursor, scope: "tools:release-a", pageSize: 2) + #expect(final.items == ["e"]) + #expect(final.nextCursor == nil) + #expect(throws: MCPPaginationError.self) { + try MCPPaginator.page(values, cursor: first.nextCursor, scope: "resources:release-a", pageSize: 2) + } + #expect(throws: MCPPaginationError.self) { + try MCPPaginator.page(values, cursor: "not+a+cursor", scope: "tools:release-a", pageSize: 2) + } + } + + @Test func packageResourcePathsAndUrisRejectTraversal() throws { + #expect(try SkillPackageResourceService.normalize(relativePath: "references/guide.md") == "references/guide.md") + let uri = SkillPackageResourceService.uri( + skillId: "swift checks", + path: "references/guide.md", + version: "release+abc123" + ) + #expect(uri == "ctx://skill/swift%20checks/file/references/guide.md?version=release%2Babc123") + #expect( + try SkillPackageResourceService.parse(uri: uri) == .init( + skillId: "swift checks", + path: "references/guide.md", + version: "release+abc123" + ) + ) + #expect(throws: (any Error).self) { try SkillPackageResourceService.normalize(relativePath: "../secret") } + #expect(throws: (any Error).self) { try SkillPackageResourceService.normalize(relativePath: "%2e%2e/secret") } + #expect(throws: (any Error).self) { try SkillPackageResourceService.parse(uri: "ctx://skill/swift/file/../secret") } } @Test func eventPathSegments() { diff --git a/services/mcp-gateway/Tests/AppTests/McpToolNamingTests.swift b/services/mcp-gateway/Tests/AppTests/McpToolNamingTests.swift index a5cca3c..bf22320 100644 --- a/services/mcp-gateway/Tests/AppTests/McpToolNamingTests.swift +++ b/services/mcp-gateway/Tests/AppTests/McpToolNamingTests.swift @@ -9,7 +9,108 @@ import VaporTesting @Suite("MCP tool naming (colon-free wire names)", .serialized) struct McpToolNamingTests { - @Test("tools/list uses mycontext_catalog and bare slugs; legacy colon names rejected on tools/call") + @Test("Streamable HTTP headers validate versions, accepts, and origins") + func streamableHTTPHeaders() async throws { + try await withMcpToolNamingApp { app in + let account = Account(githubId: 920_000, login: "mcp-headers", email: "headers@example.com") + try await account.save(on: app.db) + let project = Project(accountId: account.id!, name: "MCP Headers", slug: "mcp-headers", subdomain: "mcpheaders") + try await project.save(on: app.db) + let rawKey = "mcp_headerkey0000000000000000000" + let keyRow = ApiKey( + projectId: project.id!, name: "headers", keyPrefix: String(rawKey.prefix(12)), + keyHash: Self.sha256Hex(rawKey), status: "active" + ) + try await keyRow.save(on: app.db) + let listBody = #"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"# + + func request( + body: String = #"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#, + version: String? = nil, + accept: String? = nil, + origin: String? = nil + ) async throws -> (HTTPStatus, String) { + var status = HTTPStatus.internalServerError + var responseBody = "" + try await app.testing().test(.POST, "/mcp", body: ByteBuffer(string: body), beforeRequest: { req in + req.headers.replaceOrAdd(name: "X-API-Key", value: rawKey) + req.headers.replaceOrAdd(name: .contentType, value: "application/json") + if let version { req.headers.replaceOrAdd(name: "MCP-Protocol-Version", value: version) } + if let accept { req.headers.replaceOrAdd(name: .accept, value: accept) } + if let origin { req.headers.replaceOrAdd(name: .origin, value: origin) } + }, afterResponse: { + status = $0.status + responseBody = $0.body.string + }) + return (status, responseBody) + } + + let headerless = try await request(body: listBody) + #expect(headerless.0 == .ok) + #expect(!headerless.1.contains("\"outputSchema\"")) + #expect(!headerless.1.contains("\"annotations\"")) + let latest = try await request( + body: listBody, + version: "2025-11-25", + accept: "application/json, text/event-stream" + ) + #expect(latest.0 == .ok) + #expect(latest.1.contains("\"outputSchema\"")) + let legacyCall = try await request( + body: #"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"resolve_context","arguments":{"request":"inspect legacy response"}}}"#, + version: "2025-03-26" + ) + #expect(legacyCall.0 == .ok) + #expect(!legacyCall.1.contains("\"structuredContent\"")) + #expect(!legacyCall.1.contains("resource_link")) + #expect(legacyCall.1.contains("\"type\":\"text\"")) + #expect((try await request(version: "2099-01-01")).0 == .badRequest) + #expect((try await request(version: " ")).0 == .badRequest) + #expect((try await request(accept: "application/json")).0 == .badRequest) + #expect((try await request(origin: "https://attacker.example")).0 == .forbidden) + #expect((try await request(origin: "http://localhost:3000")).0 == .ok) + + let wrongJSONRPC = try await request( + body: #"{"jsonrpc":"1.0","id":2,"method":"tools/list","params":{}}"# + ) + #expect(wrongJSONRPC.0 == .badRequest) + #expect(wrongJSONRPC.1.contains("\"code\":-32600")) + let missingId = try await request(body: #"{"jsonrpc":"2.0","method":"tools/list","params":{}}"#) + #expect(missingId.0 == .badRequest) + #expect(missingId.1.contains("non-null id")) + let nullId = try await request(body: #"{"jsonrpc":"2.0","id":null,"method":"tools/list","params":{}}"#) + #expect(nullId.0 == .badRequest) + let notification = try await request(body: #"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + #expect(notification.0 == .accepted) + #expect(notification.1.isEmpty) + let notificationWithId = try await request( + body: #"{"jsonrpc":"2.0","id":3,"method":"notifications/initialized"}"# + ) + #expect(notificationWithId.0 == .badRequest) + #expect(notificationWithId.1.contains("notifications must not include an id")) + + func getStream(version: String? = nil, accept: String? = nil) async throws -> HTTPStatus { + var status = HTTPStatus.internalServerError + try await app.testing().test(.GET, "/mcp", beforeRequest: { req in + req.headers.replaceOrAdd(name: "X-API-Key", value: rawKey) + if let version { req.headers.replaceOrAdd(name: "MCP-Protocol-Version", value: version) } + if let accept { req.headers.replaceOrAdd(name: .accept, value: accept) } + }, afterResponse: { status = $0.status }) + return status + } + #expect(try await getStream(version: "2099-01-01", accept: "text/event-stream") == .badRequest) + #expect(try await getStream(version: "2025-11-25", accept: "application/json") == .badRequest) + + let unknownTool = try await request( + body: #"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"missing-tool","arguments":{}}}"#, + version: "2025-11-25" + ) + #expect(unknownTool.0 == .badRequest) + #expect(unknownTool.1.contains("\"code\":-32602")) + } + } + + @Test("tools/list defaults to three canonical tools; aliases stay hidden and legacy compiled tools require opt-in") func toolsListAndCallWireNames() async throws { try await withMcpToolNamingApp { app in let account = Account(githubId: 920_001, login: "mcp-name-1", email: "m1@example.com") @@ -71,6 +172,14 @@ struct McpToolNamingTests { sideEffectLevel: "read" ) try await cap.save(on: app.db) + let collidingCap = CapabilityDef( + compiledSkillId: compiled.id!, + capabilityName: MCPConstants.resolveContextToolName, + type: "tool", + schemaJson: schemaJson, + sideEffectLevel: "read" + ) + try await collidingCap.save(on: app.db) project.activeReleaseId = release.id try await project.save(on: app.db) @@ -84,14 +193,27 @@ struct McpToolNamingTests { beforeRequest: { req in req.headers.replaceOrAdd(name: "X-API-Key", value: rawKey) req.headers.replaceOrAdd(name: .contentType, value: "application/json") + req.headers.replaceOrAdd(name: "MCP-Protocol-Version", value: "2025-11-25") }, afterResponse: { res in #expect(res.status == .ok) let text = res.body.string - #expect(text.contains("\"name\":\"mycontext_catalog\"")) - #expect(text.contains("\"name\":\"demo-skill\"")) + let object = try! JSONSerialization.jsonObject(with: Data(text.utf8)) as! [String: Any] + let result = object["result"] as! [String: Any] + let tools = result["tools"] as! [[String: Any]] + let names = tools.compactMap { $0["name"] as? String } + #expect(names.filter { $0 == "resolve_context" }.count == 1) + #expect(names.filter { $0 == "get_skill" }.count == 1) + #expect(names.filter { $0 == "report_skill_feedback" }.count == 1) + #expect(Set(names) == Set(MCPConstants.runtimeToolNames)) + #expect(!names.contains("demo-skill")) + #expect(!names.contains("mycontext_catalog")) + #expect(!names.contains("discover_skills")) + #expect(!names.contains("list_capabilities")) #expect(!text.contains("mycontext:catalog")) #expect(!text.contains("skill:demo-skill")) + #expect(text.contains("\"outputSchema\"")) + #expect(text.contains("\"annotations\"")) } ) @@ -108,6 +230,7 @@ struct McpToolNamingTests { beforeRequest: { req in req.headers.replaceOrAdd(name: "X-API-Key", value: rawKey) req.headers.replaceOrAdd(name: .contentType, value: "application/json") + req.headers.replaceOrAdd(name: "MCP-Protocol-Version", value: "2025-11-25") }, afterResponse: { res in status = res.status @@ -117,9 +240,41 @@ struct McpToolNamingTests { } #expect(try await postToolsCall(name: "mycontext_catalog") == .ok) + #expect(try await postToolsCall(name: "discover_skills") == .ok) + #expect(try await postToolsCall(name: "list_capabilities") == .ok) + #expect(try await postToolsCall(name: "demo-skill") == .badRequest) + #expect(try await postToolsCall(name: "mycontext:catalog") == .badRequest) + #expect(try await postToolsCall(name: "skill:demo-skill") == .badRequest) + + let settings = ProjectRuntimeSettings() + settings.$project.id = project.id! + settings.telemetryEnabled = true + settings.telemetryRetentionDays = 30 + settings.semanticEnabled = false + settings.feedbackIssueCreationEnabled = false + settings.providerPreferencesJson = #"{"legacy_compiled_tools_enabled":true}"# + try await settings.save(on: app.db) + + try await app.testing().test( + .POST, + "/mcp", + body: ByteBuffer(string: listBody), + beforeRequest: { req in + req.headers.replaceOrAdd(name: "X-API-Key", value: rawKey) + req.headers.replaceOrAdd(name: .contentType, value: "application/json") + req.headers.replaceOrAdd(name: "MCP-Protocol-Version", value: "2025-11-25") + }, + afterResponse: { res in + #expect(res.status == .ok) + let object = try! JSONSerialization.jsonObject(with: Data(res.body.string.utf8)) as! [String: Any] + let result = object["result"] as! [String: Any] + let tools = result["tools"] as! [[String: Any]] + let names = tools.compactMap { $0["name"] as? String } + #expect(names.contains("demo-skill")) + #expect(names.filter { $0 == "resolve_context" }.count == 1) + } + ) #expect(try await postToolsCall(name: "demo-skill") == .ok) - #expect(try await postToolsCall(name: "mycontext:catalog") == .notFound) - #expect(try await postToolsCall(name: "skill:demo-skill") == .notFound) } } @@ -238,7 +393,7 @@ struct McpToolNamingTests { } } - @Test("mycontext_catalog routes across tools resources and prompts and can load full skill bodies") + @Test("mycontext_catalog delegates to canonical resolution while preserving legacy request wrappers") func catalogRouteAndSkillModes() async throws { try await withMcpToolNamingApp { app in let account = Account(githubId: 920_003, login: "mcp-name-3", email: "m3@example.com") @@ -298,9 +453,8 @@ struct McpToolNamingTests { let overview = try await Self.postCatalogCall(argumentsJson: "{}", rawKey: rawKey, app: app) #expect(overview.contains("# MCP catalog")) - #expect(overview.contains("frontend-debugging")) - #expect(overview.contains("architecture-context")) - #expect(overview.contains("review-guidance")) + #expect(overview.contains("Resolved by `resolve_context`")) + #expect(overview.contains("structuredContent")) let route = try await Self.postCatalogCall( argumentsJson: #"{"mode":"route","task":"I need to plan backend architecture changes","limit":"3"}"#, @@ -308,29 +462,24 @@ struct McpToolNamingTests { app: app ) #expect(route.contains("# MCP catalog route")) - #expect(route.contains("Architecture context")) - #expect(route.contains("resources/read ctx://skill/architecture-context")) - #expect(route.contains("tools/call mycontext_catalog")) - #expect(route.contains("prompts/get review-guidance")) - #expect(route.contains("tools/call frontend-debugging")) + #expect(route.contains("Resolved by `resolve_context`")) + #expect(route.contains("resolutionTrace")) let resourceSkill = try await Self.postCatalogCall( argumentsJson: #"{"mode":"skill","skill":"ctx://skill/architecture-context"}"#, rawKey: rawKey, app: app ) - #expect(resourceSkill.contains("# Architecture Context")) - #expect(resourceSkill.contains("Exposure: resource")) - #expect(resourceSkill.contains("Backend architecture details live here.")) + #expect(resourceSkill.contains("\"isError\":true")) + #expect(resourceSkill.contains("not active in this project")) let promptSkill = try await Self.postCatalogCall( argumentsJson: #"{"mode":"skill","skill":"review-guidance"}"#, rawKey: rawKey, app: app ) - #expect(promptSkill.contains("# Review Guidance")) - #expect(promptSkill.contains("Exposure: prompt")) - #expect(promptSkill.contains("Prioritize bugs and regressions.")) + #expect(promptSkill.contains("\"isError\":true")) + #expect(promptSkill.contains("not active in this project")) let catalogCalls = try await RequestLog.query(on: app.db) .filter(\.$project.$id == project.id!) @@ -370,23 +519,100 @@ struct McpToolNamingTests { compiled.priority = document.priority; compiled.version = document.version; compiled.sourceChecksum = document.source.checksum compiled.canonicalJson = SkillRuntimeJSON.encode(document); compiled.clarificationJson = "[]"; compiled.clarificationRequired = false try await compiled.save(on: app.db) + let packageFile = SkillPackageFile() + packageFile.$skillPackage.id = pkg.id! + packageFile.path = "references/guide.md" + packageFile.content = Data("Reference package guidance".utf8) + packageFile.contentType = "text/markdown" + packageFile.byteCount = packageFile.content.count + packageFile.checksum = Self.sha256Hex("Reference package guidance") + try await packageFile.save(on: app.db) project.activeReleaseId = release.id; try await project.save(on: app.db) let inventory = #"[{"server":"linear","name":"create_issue","description":"Create issue","provider":"linear"}]"# - let resolved = try await Self.postRuntimeCall(name: "resolve_context", arguments: ["request": "preserve follow-up issue", "available_tools": inventory], rawKey: rawKey, app: app) + let resolved = try await Self.postRuntimeCall( + name: "resolve_context", + argumentsJson: #"{"request":"preserve follow-up issue","event":"non_blocking_issue_discovered","context":{"workspace":"runtime"},"available_tools":\#(inventory)}"#, + rawKey: rawKey, + app: app + ) #expect(resolved.contains("incidental-issues")) #expect(resolved.contains("create_issue")) #expect(resolved.contains("capabilityBindings")) + #expect(resolved.contains("\"structuredContent\"")) + #expect(resolved.contains("\"isError\":false")) + let resolvedEnvelope = try #require(JSONSerialization.jsonObject(with: Data(resolved.utf8)) as? [String: Any]) + let resolvedResult = try #require(resolvedEnvelope["result"] as? [String: Any]) + let resolvedStructured = try #require(resolvedResult["structuredContent"] as? [String: Any]) + let resolvedContent = try #require(resolvedResult["content"] as? [[String: Any]]) + let fallbackText = try #require(resolvedContent.first?["text"] as? String) + let fallbackObject = try #require(JSONSerialization.jsonObject(with: Data(fallbackText.utf8)) as? [String: Any]) + #expect(resolvedStructured["traceId"] as? String == fallbackObject["traceId"] as? String) + #expect(resolvedStructured["schemaVersion"] as? Int == fallbackObject["schemaVersion"] as? Int) + + let invalid = try await Self.postRuntimeCall( + name: "resolve_context", + argumentsJson: "{}", + rawKey: rawKey, + app: app, + expectedStatus: .badRequest + ) + #expect(invalid.contains("\"code\":-32602")) + #expect(invalid.contains("request is required")) + let discovered = try await Self.postRuntimeCall(name: "discover_skills", arguments: ["query": "found unrelated bug", "event": "non_blocking_issue_discovered"], rawKey: rawKey, app: app) #expect(discovered.contains("eventCanonical")) #expect(discovered.contains("incidental-issues")) let fetched = try await Self.postRuntimeCall(name: "get_skill", arguments: ["skill_id": "incidental-issues", "version": "1.0.0"], rawKey: rawKey, app: app) #expect(fetched.contains("# Preserve issues")) + #expect(fetched.contains("resource_link")) + #expect(fetched.replacingOccurrences(of: "\\/", with: "/").contains("ctx://skill/incidental-issues/file/references/guide.md?version=1.0.0")) + let fetchedFile = try await Self.postRuntimeCall( + name: "get_skill", + arguments: ["skill_id": "incidental-issues", "version": "1.0.0", "path": "references/guide.md"], + rawKey: rawKey, + app: app + ) + #expect(fetchedFile.contains("Reference package guidance")) + #expect(fetchedFile.contains("\"encoding\":\"utf-8\"")) + + let resourceBody = #"{"jsonrpc":"2.0","id":11,"method":"resources/read","params":{"uri":"ctx://skill/incidental-issues/file/references/guide.md?version=1.0.0"}}"# + try await app.testing().test(.POST, "/mcp", body: ByteBuffer(string: resourceBody), beforeRequest: { request in + request.headers.replaceOrAdd(name: "X-API-Key", value: rawKey) + request.headers.replaceOrAdd(name: .contentType, value: "application/json") + request.headers.replaceOrAdd(name: "MCP-Protocol-Version", value: "2025-11-25") + }, afterResponse: { response in + #expect(response.status == .ok) + #expect(response.body.string.contains("Reference package guidance")) + }) let capabilities = try await Self.postRuntimeCall(name: "list_capabilities", arguments: ["skill_id": "incidental-issues", "available_tools": inventory], rawKey: rawKey, app: app) #expect(capabilities.contains("issue.create")) - let feedback = try await Self.postRuntimeCall(name: "report_skill_feedback", arguments: ["skill_id": "incidental-issues", "version": "1.0.0", "category": "missing_guidance", "summary": "Explain duplicate search"], rawKey: rawKey, app: app) + let staleFeedback = try await Self.postRuntimeCall( + name: "report_skill_feedback", + argumentsJson: #"{"skill_id":"incidental-issues","version":"0.9.0","category":"missing_guidance","summary":"Stale observation","evidence":"Observed against an inactive version."}"#, + rawKey: rawKey, + app: app + ) + #expect(staleFeedback.contains("\"isError\":true")) + #expect(try await SkillFeedbackRecord.query(on: app.db).count() == 0) + let missingEvidence = try await Self.postRuntimeCall( + name: "report_skill_feedback", + argumentsJson: #"{"skill_id":"incidental-issues","version":"1.0.0","category":"missing_guidance","summary":"Missing evidence"}"#, + rawKey: rawKey, + app: app, + expectedStatus: .badRequest + ) + #expect(missingEvidence.contains("\"code\":-32602")) + #expect(try await SkillFeedbackRecord.query(on: app.db).count() == 0) + let feedback = try await Self.postRuntimeCall( + name: "report_skill_feedback", + argumentsJson: #"{"skill_id":"incidental-issues","version":"1.0.0","category":"missing_guidance","summary":"Explain duplicate search","evidence":"The resolver omitted the duplicate-search step in trace example 42.","create_issue":false}"#, + rawKey: rawKey, + app: app + ) #expect(feedback.contains("effectStatus")) #expect(feedback.contains("draft")) + #expect(feedback.contains("\"structuredContent\"")) #expect(try await SkillFeedbackRecord.query(on: app.db).count() == 1) } } @@ -399,13 +625,24 @@ struct McpToolNamingTests { private static func postRuntimeCall(name: String, arguments: [String: String], rawKey: String, app: Application) async throws -> String { let argumentsData = try JSONEncoder().encode(arguments) let argumentsJson = String(data: argumentsData, encoding: .utf8)! + return try await postRuntimeCall(name: name, argumentsJson: argumentsJson, rawKey: rawKey, app: app) + } + + private static func postRuntimeCall( + name: String, + argumentsJson: String, + rawKey: String, + app: Application, + expectedStatus: HTTPStatus = .ok + ) async throws -> String { let body = #"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"\#(name)","arguments":\#(argumentsJson)}}"# var result = "" try await app.testing().test(.POST, "/mcp", body: ByteBuffer(string: body), beforeRequest: { req in req.headers.replaceOrAdd(name: "X-API-Key", value: rawKey) req.headers.replaceOrAdd(name: .contentType, value: "application/json") + req.headers.replaceOrAdd(name: "MCP-Protocol-Version", value: "2025-11-25") }, afterResponse: { response in - #expect(response.status == .ok) + #expect(response.status == expectedStatus) result = response.body.string }) return result @@ -488,6 +725,7 @@ struct McpToolNamingTests { beforeRequest: { req in req.headers.replaceOrAdd(name: "X-API-Key", value: rawKey) req.headers.replaceOrAdd(name: .contentType, value: "application/json") + req.headers.replaceOrAdd(name: "MCP-Protocol-Version", value: "2025-11-25") }, afterResponse: { res in #expect(res.status == .ok) diff --git a/services/mcp-gateway/Tests/AppTests/SkillRuntimeHardeningTests.swift b/services/mcp-gateway/Tests/AppTests/SkillRuntimeHardeningTests.swift new file mode 100644 index 0000000..6e00f92 --- /dev/null +++ b/services/mcp-gateway/Tests/AppTests/SkillRuntimeHardeningTests.swift @@ -0,0 +1,418 @@ +import Foundation +import Fluent +import Testing +import Vapor +import VaporTesting +import Yams +@testable import App + +@Suite("Portable skill runtime hardening", .serialized) +struct SkillRuntimeHardeningTests { + @Test("Standard validation enforces folder identity, description, slug, and reserved names") + func standardValidationMatrix() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("skill-validation-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + func report(folder: String, name: String, description: String?) throws -> ValidationReport { + let directory = root.appendingPathComponent(folder) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var lines = ["---", "name: \(name)"] + if let description { lines.append("description: \(description)") } + lines.append(contentsOf: ["kind: task", "scope: task", "---", "Body"]) + let file = directory.appendingPathComponent("SKILL.md") + try lines.joined(separator: "\n").write(to: file, atomically: true, encoding: .utf8) + return Validator.validate(try SkillParser.parse(fileURL: file, basePath: root.path)) + } + + #expect(try report(folder: "valid-skill", name: "valid-skill", description: "Valid description").isValid) + #expect(try !report(folder: "different-folder", name: "different-name", description: "Valid description").isValid) + #expect(try !report(folder: "double--hyphen", name: "double--hyphen", description: "Valid description").isValid) + #expect(try !report(folder: "trailing-", name: "trailing-", description: "Valid description").isValid) + #expect(try !report(folder: "missing-description", name: "missing-description", description: nil).isValid) + #expect(try !report(folder: "long-description", name: "long-description", description: String(repeating: "a", count: 1025)).isValid) + #expect(try !report(folder: "resolve_context", name: "resolve_context", description: "Reserved").isValid) + #expect(try !report(folder: "mycontext_catalog", name: "mycontext_catalog", description: "Reserved alias").isValid) + } + + @Test("Duplicate skill IDs are rejected within one release") + func duplicateIDs() throws { + let rootA = FileManager.default.temporaryDirectory.appendingPathComponent("duplicate-a-\(UUID().uuidString)/same-skill") + let rootB = FileManager.default.temporaryDirectory.appendingPathComponent("duplicate-b-\(UUID().uuidString)/same-skill") + try FileManager.default.createDirectory(at: rootA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: rootB, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: rootA.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: rootB.deletingLastPathComponent()) + } + let contents = "---\nname: same-skill\ndescription: Same identifier\n---\nBody" + let fileA = rootA.appendingPathComponent("SKILL.md") + let fileB = rootB.appendingPathComponent("SKILL.md") + try contents.write(to: fileA, atomically: true, encoding: .utf8) + try contents.write(to: fileB, atomically: true, encoding: .utf8) + let first = try SkillParser.parse(fileURL: fileA, basePath: rootA.deletingLastPathComponent().path) + let second = try SkillParser.parse(fileURL: fileB, basePath: rootB.deletingLastPathComponent().path) + let errors = Validator.duplicateSkillIDErrors([first, second]) + #expect(errors.count == 2) + #expect(errors.allSatisfy { $0.message.contains("duplicate skill id") }) + } + + @Test("Standard Agent Skills receive deterministic safe defaults") + func standardDefaults() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("standard-skill-\(UUID().uuidString)") + let directory = root.appendingPathComponent("review-code") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = directory.appendingPathComponent("SKILL.md") + try """ + --- + name: review-code + description: Review a change for correctness. + license: Apache-2.0 + compatibility: Requires git. + metadata: + owner: platform + allowed-tools: Read Grep + --- + Review the requested change. + """.write(to: file, atomically: true, encoding: .utf8) + + let parsed = try SkillParser.parse(fileURL: file, basePath: root.path) + let package = SkillPackage(releaseId: UUID(), path: parsed.path, name: parsed.name) + let result = SkillCanonicalCompiler.compile( + parsed: parsed, + package: package, + repository: "example/skills", + revision: "abc123" + ) + + #expect(result.questions.isEmpty) + #expect(!result.document.validation.clarificationRequired) + #expect(result.document.kind == .task) + #expect(result.document.scope == .task) + #expect(result.document.activation.mode == .intent) + #expect(result.document.enforcement == .advisory) + #expect(result.document.priority == 50) + #expect(result.document.version == "abc123+\(parsed.hash!.prefix(12))") + #expect(result.document.standardFrontmatterJson?.contains("Apache-2.0") == true) + #expect(Compiler.exposureType(for: parsed) == "resource") + } + + @Test("Source policy sidecar is parsed without touching SKILL markdown") + func sidecarPolicy() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("skill-policy-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root.appendingPathComponent(".mycontext"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try """ + version: 1 + skills: + review-code: + base_checksum: deadbeef + metadata: + exposure: resource + scope: repository + priority: 75 + avoid_when: [frontend only] + activation: + mode: intent + intents: [review code] + events: [] + tags: [review] + examples: [Review this pull request] + requires: + - capability: repository.read + required: true + on_missing: fail_activation + conflicts_with: [write-without-review] + """.write(to: root.appendingPathComponent(".mycontext/skills.yaml"), atomically: true, encoding: .utf8) + + let policies = try SkillCanonicalCompiler.sourcePolicies(repoRoot: root) + #expect(policies["review-code"]?.policy.baseChecksum == "deadbeef") + #expect(policies["review-code"]?.policy.metadata.scope == .repository) + #expect(policies["review-code"]?.policy.metadata.priority == 75) + #expect(policies["review-code"]?.policy.metadata.exposure == "resource") + #expect(policies["review-code"]?.policy.metadata.avoidWhen == ["frontend only"]) + #expect(policies["review-code"]?.policy.metadata.activation?.examples == ["Review this pull request"]) + #expect(policies["review-code"]?.policy.metadata.requires?.first?.onMissing == .failActivation) + #expect(policies["review-code"]?.policy.metadata.conflictsWith == ["write-without-review"]) + } + + @Test("Configured central skill repository compiles all six packages without assignments") + func centralSkillRepositoryCompatibility() throws { + guard let path = ProcessInfo.processInfo.environment["CENTRAL_SKILLS_REPO"], !path.isEmpty else { + return + } + let root = URL(fileURLWithPath: path).standardizedFileURL + let policies = try SkillCanonicalCompiler.sourcePolicies(repoRoot: root) + let skillFiles = try FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ).compactMap { directory -> URL? in + let candidate = directory.appendingPathComponent("SKILL.md") + return FileManager.default.fileExists(atPath: candidate.path) ? candidate : nil + }.sorted { $0.path < $1.path } + #expect(skillFiles.count == 6) + #expect(policies.count == 6) + + for file in skillFiles { + let parsed = try SkillParser.parse(fileURL: file, basePath: root.path) + let validation = Validator.validate(parsed) + #expect(validation.isValid, "\(parsed.path): \(validation.errors.map(\.message).joined(separator: "; "))") + let package = SkillPackage(releaseId: UUID(), path: parsed.path, name: parsed.name) + let result = SkillCanonicalCompiler.compile( + parsed: parsed, + package: package, + repository: "countablenewt/skills", + revision: "central-test", + sourcePolicy: policies[parsed.name]?.policy + ) + #expect(!result.document.validation.clarificationRequired) + #expect(result.document.activation.mode != .explicit) + #expect(Compiler.exposureType( + for: parsed, + policyExposure: policies[parsed.name]?.policy.metadata.exposure + ) == "resource") + } + } + + @Test("Source policy sidecar rejects invalid structure and runtime values") + func invalidSidecarPolicyFailsClosed() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-skill-policy-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root.appendingPathComponent(".mycontext"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent(".mycontext/skills.yaml") + + func failure(for yaml: String) throws -> String { + try yaml.write(to: file, atomically: true, encoding: .utf8) + do { + _ = try SkillCanonicalCompiler.sourcePolicies(repoRoot: root) + return "" + } catch { + return error.localizedDescription + } + } + + #expect(try failure(for: "skills: {}\n").contains("version is required")) + #expect(try failure(for: "version: 2\nskills: {}\n").contains("version 2 is unsupported")) + #expect(try failure(for: "version: 1\nskills: []\n").contains("skills is required and must be a mapping")) + #expect(try failure(for: "version: 1\nskills:\n review-code: value\n").contains("skills.review-code")) + #expect(try failure(for: "version: 1\nskills:\n review-code:\n metadata:\n scope: repositorry\n").contains("skills.review-code")) + #expect(try failure(for: "version: 1\nskills:\n review-code:\n metadata:\n activation:\n mode: sometimes\n").contains("skills.review-code")) + #expect(try failure(for: "version: 1\nskills:\n review-code:\n metadata:\n exposure: guidance\n").contains("metadata.exposure")) + #expect(try failure(for: "version: 1\nskills:\n review-code:\n metadata:\n priority: 101\n").contains("metadata.priority")) + } + + @Test("Override selection is repository-aware and deterministic") + func deterministicOverrideSelection() throws { + let currentRepository = UUID() + let otherRepository = UUID() + + func override(scope: SkillScope, repository: UUID?, updatedAt: Date) -> SkillRuntimeOverride { + let row = SkillRuntimeOverride() + row.id = UUID() + row.skillId = "review-code" + row.scope = scope.rawValue + row.metadataJson = "{}" + row.sourceChecksum = nil + row.baseChecksum = nil + row.isStale = false + row.$repoConnection.id = repository + row.updatedAt = updatedAt + return row + } + + let projectFallback = override(scope: .repository, repository: nil, updatedAt: Date(timeIntervalSince1970: 300)) + let currentRepo = override(scope: .repository, repository: currentRepository, updatedAt: Date(timeIntervalSince1970: 100)) + let otherRepo = override(scope: .task, repository: otherRepository, updatedAt: Date(timeIntervalSince1970: 400)) + let currentTask = override(scope: .task, repository: currentRepository, updatedAt: Date(timeIntervalSince1970: 200)) + + let selected = Compiler.selectOverride( + from: [projectFallback, currentTask, otherRepo, currentRepo], + repoConnectionId: currentRepository, + preferredScope: .repository + ) + #expect(selected?.id == currentRepo.id) + + let projectSelected = Compiler.selectOverride( + from: [otherRepo, projectFallback], + repoConnectionId: nil, + preferredScope: .repository + ) + #expect(projectSelected?.id == projectFallback.id) + } + + @Test("Writeback merges the sidecar and never reconstructs SKILL markdown") + func writebackSidecarMerge() throws { + let document = CompiledSkillDocument( + schemaVersion: 1, + id: "review-code", + name: "review-code", + description: "Review code", + kind: .task, + scope: .repository, + activation: .init(mode: .intent, intents: ["review"], events: [], tags: [], examples: []), + enforcement: .advisory, + priority: 50, + requires: [], + conflictsWith: [], + instructions: "# Authored body\nDo not rewrite me.", + source: .init(repository: "example/skills", path: "review-code/SKILL.md", revision: "abc", checksum: "checksum"), + version: "abc+checksum", + lifecycle: nil, + validation: .init(clarificationRequired: false, missingFields: [], warnings: []) + ) + let merged = try SkillMetadataWritebackService.mergedSidecar( + existing: "version: 1\nskills:\n other-skill:\n base_checksum: other\n metadata:\n priority: 10\n review-code:\n extension_key: keep-me\n metadata:\n provider_extension: preserved\n priority: 1\n", + document: document + ) + let root = try load(yaml: merged) as? [String: Any] + let skills = root?["skills"] as? [String: Any] + let review = skills?["review-code"] as? [String: Any] + let metadata = review?["metadata"] as? [String: Any] + #expect(skills?["other-skill"] != nil) + #expect(review?["extension_key"] as? String == "keep-me") + #expect(metadata?["provider_extension"] as? String == "preserved") + #expect(metadata?["priority"] as? Int == 50) + #expect(!merged.contains("Authored body")) + #expect(!merged.contains("Do not rewrite me")) + } + + @Test("Resolver applies negative hints, assignment activation, and capability fallback deterministically") + func deterministicResolverRules() { + #expect(SkillRuntimeResolver.avoidMatch(["frontend only"], tokens: ["frontend", "only", "layout"])) + #expect(!SkillRuntimeResolver.avoidMatch(["frontend only"], tokens: ["backend", "layout"])) + #expect(SkillRuntimeResolver.activationMatches("always", isCurrent: false, eventMatch: false, intentMatch: false)) + #expect(SkillRuntimeResolver.activationMatches("event", isCurrent: false, eventMatch: true, intentMatch: false)) + #expect(!SkillRuntimeResolver.activationMatches("event", isCurrent: false, eventMatch: false, intentMatch: true)) + #expect(SkillRuntimeResolver.activationMatches("intent", isCurrent: false, eventMatch: false, intentMatch: true)) + #expect(!SkillRuntimeResolver.activationMatches("intent", isCurrent: false, eventMatch: true, intentMatch: false)) + #expect(!SkillRuntimeResolver.activationMatches("explicit", isCurrent: false, eventMatch: true, intentMatch: true)) + #expect(SkillRuntimeResolver.activationMatches("explicit", isCurrent: true, eventMatch: false, intentMatch: false)) + #expect(SkillRuntimeResolver.scopeHasContext(.global, context: .init())) + #expect(!SkillRuntimeResolver.scopeHasContext(.repository, context: .init())) + #expect(SkillRuntimeResolver.scopeHasContext( + .repository, + context: .init(repository: "Stygian-Tech/my-context-protocol") + )) + + let binding = SkillRuntimeResolver.bind( + .init(capability: "issue.create", required: true, onMissing: .failActivation), + tools: [] + ) + #expect(binding.missing) + #expect(binding.fallback == "fail_activation") + } + + @Test("Package support files are persisted with bounded relative paths and checksums") + func packageFilePersistence() async throws { + try await TestProcessEnvGate.run { + let keys = ["USE_SQLITE", "USE_MEMORY_SESSIONS", "DATABASE_URL", "SUPABASE_DB_URL"] + let saved = Dictionary(uniqueKeysWithValues: keys.map { ($0, ProcessInfo.processInfo.environment[$0]) }) + setenv("USE_SQLITE", "1", 1) + setenv("USE_MEMORY_SESSIONS", "1", 1) + unsetenv("DATABASE_URL") + unsetenv("SUPABASE_DB_URL") + defer { + for key in keys { + if let value = saved[key] ?? nil { setenv(key, value, 1) } else { unsetenv(key) } + } + } + let app = try await Application.make(.testing) + do { + try await configure(app) + let account = Account(githubId: 939_001, login: "package-files", email: "package-files@example.com") + try await account.save(on: app.db) + let project = Project(accountId: account.id!, name: "Package files", slug: "package-files", subdomain: "packagefiles") + try await project.save(on: app.db) + let release = Release(projectId: project.id!, commitSha: "abc", status: "pending") + try await release.save(on: app.db) + let package = SkillPackage(releaseId: release.id!, path: "review/SKILL.md", name: "review") + try await package.save(on: app.db) + + let root = FileManager.default.temporaryDirectory.appendingPathComponent("package-files-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root.appendingPathComponent("references"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try "# Skill".write(to: root.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + try "Reference material".write(to: root.appendingPathComponent("references/guide.md"), atomically: true, encoding: .utf8) + + try await SyncPipeline.persistPackageFiles(package: package, skillDirectory: root, db: app.db) + let rows = try await SkillPackageFile.query(on: app.db).all() + #expect(rows.count == 1) + #expect(rows[0].path == "references/guide.md") + #expect(rows[0].byteCount == Data("Reference material".utf8).count) + #expect(rows[0].checksum.count == 64) + #expect(rows[0].contentType == "text/markdown") + + #expect(throws: PackageFileIngestionError.unsafeEntry) { + _ = try SyncPipeline.safePackageRelativePath( + fileURL: root.deletingLastPathComponent().appendingPathComponent("outside.txt"), + root: root + ) + } + + let symlinkRoot = FileManager.default.temporaryDirectory.appendingPathComponent("package-symlink-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: symlinkRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: symlinkRoot) } + let outside = symlinkRoot.deletingLastPathComponent().appendingPathComponent("outside-\(UUID().uuidString).txt") + try "outside".write(to: outside, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: outside) } + try FileManager.default.createSymbolicLink(at: symlinkRoot.appendingPathComponent("linked.txt"), withDestinationURL: outside) + let symlinkPackage = SkillPackage(releaseId: release.id!, path: "symlink/SKILL.md", name: "symlink") + try await symlinkPackage.save(on: app.db) + await #expect(throws: PackageFileIngestionError.unsafeEntry) { + try await SyncPipeline.persistPackageFiles(package: symlinkPackage, skillDirectory: symlinkRoot, db: app.db) + } + + let countRoot = FileManager.default.temporaryDirectory.appendingPathComponent("package-count-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: countRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: countRoot) } + for index in 0...SyncPipeline.maxPackageFileCount { + FileManager.default.createFile(atPath: countRoot.appendingPathComponent("\(index).txt").path, contents: Data()) + } + let countPackage = SkillPackage(releaseId: release.id!, path: "count/SKILL.md", name: "count") + try await countPackage.save(on: app.db) + await #expect(throws: PackageFileIngestionError.boundsExceeded) { + try await SyncPipeline.persistPackageFiles(package: countPackage, skillDirectory: countRoot, db: app.db) + } + let partialCount = try await SkillPackageFile.query(on: app.db) + .filter(\.$skillPackage.$id == countPackage.id!) + .count() + #expect(partialCount == 0) + + let nestedRoot = FileManager.default.temporaryDirectory.appendingPathComponent("package-nested-\(UUID().uuidString)") + let childRoot = nestedRoot.appendingPathComponent("child") + try FileManager.default.createDirectory(at: childRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: nestedRoot) } + try "Parent reference".write(to: nestedRoot.appendingPathComponent("parent.txt"), atomically: true, encoding: .utf8) + try "---\nname: child\ndescription: Child skill\n---\nChild".write( + to: childRoot.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8 + ) + try "Child secret".write(to: childRoot.appendingPathComponent("child.txt"), atomically: true, encoding: .utf8) + let nestedPackage = SkillPackage(releaseId: release.id!, path: "parent/SKILL.md", name: "parent") + try await nestedPackage.save(on: app.db) + try await SyncPipeline.persistPackageFiles(package: nestedPackage, skillDirectory: nestedRoot, db: app.db) + let nestedPaths = try await SkillPackageFile.query(on: app.db) + .filter(\.$skillPackage.$id == nestedPackage.id!) + .all() + .map(\.path) + #expect(nestedPaths == ["parent.txt"]) + + let sizeRoot = FileManager.default.temporaryDirectory.appendingPathComponent("package-size-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: sizeRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: sizeRoot) } + try Data(repeating: 0x61, count: SyncPipeline.maxPackageFileBytes + 1) + .write(to: sizeRoot.appendingPathComponent("oversized.bin")) + let sizePackage = SkillPackage(releaseId: release.id!, path: "size/SKILL.md", name: "size") + try await sizePackage.save(on: app.db) + await #expect(throws: PackageFileIngestionError.boundsExceeded) { + try await SyncPipeline.persistPackageFiles(package: sizePackage, skillDirectory: sizeRoot, db: app.db) + } + } catch { + try await app.asyncShutdown() + throw error + } + try await app.asyncShutdown() + } + } +} From 651446e2b1bf459cef381e03db0d41db5a5123db Mon Sep 17 00:00:00 2001 From: Sam Clemente Date: Sun, 30 Aug 2026 20:35:16 -0500 Subject: [PATCH 2/2] feat: make runtime administration truthful Removes unused embedding controls, requires exact assignment identities, and clarifies that feedback prepares drafts without claiming external issue creation. --- .../release-skill-metadata-dialog.tsx | 39 +++++++------ .../dashboard/skill-runtime-section.tsx | 55 +++++++------------ apps/web/lib/projects-api.ts | 8 ++- packages/mycontext-web-client/src/types.ts | 2 + 4 files changed, 50 insertions(+), 54 deletions(-) diff --git a/apps/web/components/dashboard/release-skill-metadata-dialog.tsx b/apps/web/components/dashboard/release-skill-metadata-dialog.tsx index 011c585..2ef0378 100644 --- a/apps/web/components/dashboard/release-skill-metadata-dialog.tsx +++ b/apps/web/components/dashboard/release-skill-metadata-dialog.tsx @@ -194,6 +194,7 @@ function SkillEditorRow({ const [runtimeEnforcement, setRuntimeEnforcement] = useState(skill.enforcement ?? "advisory"); const [runtimePriority, setRuntimePriority] = useState(skill.priority ?? 50); const [runtimeVersion, setRuntimeVersion] = useState(skill.version ?? "0.0.0"); + const [runtimeDirty, setRuntimeDirty] = useState(false); /** Errors returned from the API after a failed save (merged with live validation for display). */ const [serverIssues, setServerIssues] = useState([]); /** When false and body is valid non-empty, show rendered markdown; click or focus opens the editor. */ @@ -236,6 +237,7 @@ function SkillEditorRow({ setRuntimeEnforcement(skill.enforcement ?? "advisory"); setRuntimePriority(skill.priority ?? 50); setRuntimeVersion(skill.version ?? "0.0.0"); + setRuntimeDirty(false); setServerIssues([]); setSkillBodyEditing(false); }, [skill]); @@ -260,21 +262,20 @@ function SkillEditorRow({ failure_modes: listFromMultiline(failureModesText), invoke_first: invokeFirst, }, - runtime: { + }; + if (runtimeDirty) { + payload.runtime = { kind: runtimeKind, scope: runtimeScope, activation: { mode: runtimeActivation, intents: listFromMultiline(useWhenText), - events: [], - tags: [], - examples: [], }, enforcement: runtimeEnforcement, priority: runtimePriority, version: runtimeVersion.trim() || "0.0.0", - }, - }; + }; + } if (schemaDirty) { payload.replace_schema = true; payload.schema_json = schemaJson; @@ -284,6 +285,7 @@ function SkillEditorRow({ onSuccess: () => { setServerIssues([]); setSchemaDirty(false); + setRuntimeDirty(false); queryClient.invalidateQueries({ queryKey: ["compiled-skills", projectId, releaseId], }); @@ -445,9 +447,9 @@ function SkillEditorRow({ size="sm" onClick={() => writebackMutation.mutate()} disabled={writebackMutation.isPending || skill.clarification_required === true} - title={skill.clarification_required ? "Resolve and save runtime clarification before write-back" : "Open a draft GitHub pull request"} + title={skill.clarification_required ? "Resolve and save runtime clarification before opening a sidecar pull request" : "Open a draft pull request for .mycontext/skills.yaml"} > - {writebackMutation.isPending ? "Opening PR…" : "Write Back"} + {writebackMutation.isPending ? "Opening PR…" : "Open Sidecar PR"}