From 498fd2dda9bee6111f2212afa9001afdbeb06ca3 Mon Sep 17 00:00:00 2001 From: Gale W Date: Sat, 9 May 2026 11:10:42 -0400 Subject: [PATCH 1/2] server: propagate request purpose context --- Package.resolved | 10 +++--- Package.swift | 4 +-- .../HTTP/HTTPSpeechRoutes.swift | 6 ++++ .../Host/ServerModels.swift | 8 +++++ .../MCP/MCPClientIdentity.swift | 11 ++++++ .../SpeakSwiftlyServer/MCP/MCPResources.swift | 2 +- .../MCP/MCPToolCatalog.swift | 6 ++-- .../HTTPWorkflowTests.swift | 4 ++- .../HostStateTests.swift | 2 ++ .../MCPCatalogRuntimeTests.swift | 4 ++- docs/releases/v8.0.0-release-notes.md | 35 +++++++++++++++++++ 11 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 docs/releases/v8.0.0-release-notes.md diff --git a/Package.resolved b/Package.resolved index 4125363..a090cc5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a03079627c829b14ef443ac8a9e362681ae785ea566656ee2e6cded5c46a60c4", + "originHash" : "44c272f95dae15acade5866db5ed38a84ed7112253e5b392b3db7ee11f0ab403", "pins" : [ { "identity" : "async-http-client", @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gaelic-ghost/SpeakSwiftly.git", "state" : { - "revision" : "5d4b54a247fe4327901e35005b7696e7174d5df1", - "version" : "8.0.0" + "revision" : "56edcddaea00221ed10705425466f10ee57c4db8", + "version" : "9.0.0" } }, { @@ -357,8 +357,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gaelic-ghost/TextForSpeech.git", "state" : { - "revision" : "7a0a6d5280922e471c4fceaa81c9c1e4d4f33ae9", - "version" : "0.21.0" + "revision" : "183d7ef3c5e31d533697a9f59843921a86e75838", + "version" : "0.22.0" } }, { diff --git a/Package.swift b/Package.swift index 35fa540..76ae122 100644 --- a/Package.swift +++ b/Package.swift @@ -29,9 +29,9 @@ let package = Package( dependencies: [ .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.21.1"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.1.0"), - .package(url: "https://github.com/gaelic-ghost/SpeakSwiftly.git", from: "8.0.0"), + .package(url: "https://github.com/gaelic-ghost/SpeakSwiftly.git", from: "9.0.0"), .package(url: "https://github.com/ml-explore/mlx-swift-lm.git", exact: "3.31.3"), - .package(url: "https://github.com/gaelic-ghost/TextForSpeech.git", from: "0.21.0"), + .package(url: "https://github.com/gaelic-ghost/TextForSpeech.git", from: "0.22.0"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.3"), .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.0"), .package( diff --git a/Sources/SpeakSwiftlyServer/HTTP/HTTPSpeechRoutes.swift b/Sources/SpeakSwiftlyServer/HTTP/HTTPSpeechRoutes.swift index 393375b..705fa0e 100644 --- a/Sources/SpeakSwiftlyServer/HTTP/HTTPSpeechRoutes.swift +++ b/Sources/SpeakSwiftlyServer/HTTP/HTTPSpeechRoutes.swift @@ -1,4 +1,5 @@ import Hummingbird +import SpeakSwiftly func registerHTTPSpeechRoutes( on router: Router, @@ -21,6 +22,7 @@ func registerHTTPSpeechRoutes( requestContext: payload.resolvedRequestContext( defaults: httpSpeechRequestContextDefaults( route: "/speech/live", + reqPurpose: .speech, topic: "live-speech", ), ), @@ -45,6 +47,7 @@ func registerHTTPSpeechRoutes( requestContext: payload.resolvedRequestContext( defaults: httpSpeechRequestContextDefaults( route: "/speech/files", + reqPurpose: .audioFile, topic: "retained-audio-file", ), ), @@ -66,6 +69,7 @@ func registerHTTPSpeechRoutes( $0.model( requestContextDefaults: httpSpeechRequestContextDefaults( route: "/speech/batches", + reqPurpose: .audioFile, topic: "retained-audio-batch", ), ) @@ -78,9 +82,11 @@ func registerHTTPSpeechRoutes( private func httpSpeechRequestContextDefaults( route: String, + reqPurpose: SpeakSwiftly.RequestContext.RequestPurpose, topic: String, ) -> SpeechRequestContextDefaults { .init( + reqPurpose: reqPurpose, source: "http", topic: topic, attributes: [ diff --git a/Sources/SpeakSwiftlyServer/Host/ServerModels.swift b/Sources/SpeakSwiftlyServer/Host/ServerModels.swift index 2bf6e38..535b4f3 100644 --- a/Sources/SpeakSwiftlyServer/Host/ServerModels.swift +++ b/Sources/SpeakSwiftlyServer/Host/ServerModels.swift @@ -12,24 +12,30 @@ func supportedSpeechBackendDescription() -> String { } struct SpeechRequestContextDefaults { + var reqPurpose: SpeakSwiftly.RequestContext.RequestPurpose var source: String? var topic: String? var cwd: String? var repoRoot: String? var attributes: [String: String] + var prefacePolicy: SpeakSwiftly.RequestContext.PrefacePolicy? init( + reqPurpose: SpeakSwiftly.RequestContext.RequestPurpose = .speech, source: String? = nil, topic: String? = nil, cwd: String? = nil, repoRoot: String? = nil, attributes: [String: String] = [:], + prefacePolicy: SpeakSwiftly.RequestContext.PrefacePolicy? = nil, ) { + self.reqPurpose = reqPurpose self.source = source self.topic = topic self.cwd = cwd self.repoRoot = repoRoot self.attributes = attributes + self.prefacePolicy = prefacePolicy } } @@ -40,6 +46,7 @@ func makeSpeechRequestContext( defaults: SpeechRequestContextDefaults = .init(), ) -> SpeakSwiftly.RequestContext? { let merged = SpeakSwiftly.RequestContext( + reqPurpose: defaults.reqPurpose, source: requestContext?.source ?? defaults.source, topic: requestContext?.topic ?? defaults.topic, cwd: cwd ?? requestContext?.cwd ?? defaults.cwd, @@ -48,6 +55,7 @@ func makeSpeechRequestContext( defaults: defaults.attributes, request: requestContext?.attributes ?? [:], ), + prefacePolicy: requestContext?.prefacePolicy ?? defaults.prefacePolicy, ) guard merged.source != nil diff --git a/Sources/SpeakSwiftlyServer/MCP/MCPClientIdentity.swift b/Sources/SpeakSwiftlyServer/MCP/MCPClientIdentity.swift index 7f1c4ed..1ab143e 100644 --- a/Sources/SpeakSwiftlyServer/MCP/MCPClientIdentity.swift +++ b/Sources/SpeakSwiftlyServer/MCP/MCPClientIdentity.swift @@ -1,4 +1,5 @@ import MCP +import SpeakSwiftly struct MCPClientInfoSnapshot { let name: String @@ -42,12 +43,22 @@ func mcpSpeechRequestContextDefaults( } return .init( + reqPurpose: mcpRequestPurpose(for: toolName), source: "mcp", topic: toolName, attributes: attributes, ) } +private func mcpRequestPurpose(for toolName: String) -> SpeakSwiftly.RequestContext.RequestPurpose { + switch toolName { + case "generate_audio_file", "generate_batch": + .audioFile + default: + .speech + } +} + private func mcpClientDisplayName(from clientInfo: MCPClientInfoSnapshot) -> String { let displayName = if let title = clientInfo.title, !title.isEmpty { title diff --git a/Sources/SpeakSwiftlyServer/MCP/MCPResources.swift b/Sources/SpeakSwiftlyServer/MCP/MCPResources.swift index e4bffd9..42bd728 100644 --- a/Sources/SpeakSwiftlyServer/MCP/MCPResources.swift +++ b/Sources/SpeakSwiftlyServer/MCP/MCPResources.swift @@ -331,7 +331,7 @@ private func voiceProfilesGuideMarkdown() -> String { 7. Use `update_voice_profile_name` when the user wants to keep a user-owned stored voice but correct or improve its visible profile name. 8. Use `reroll_voice_profile` when the user wants SpeakSwiftly to rebuild one user-owned stored profile from its original source inputs while keeping the same profile name. System profile rerolls create or target a user-owned copy in SpeakSwiftly rather than mutating the built-in in place. 9. Provide `transcript` to `create_voice_profile_from_audio` when the user knows the spoken words already; omit it only when transcription is actually needed. - 10. The MCP surface fills client and tool provenance in `request_context` by default; pass `cwd`, `repo_root`, or `request_context` only when path or caller metadata needs to be more specific. + 10. The MCP surface fills client, tool, and request-purpose provenance in `request_context` by default; pass `cwd`, `repo_root`, or `request_context` only when path or caller metadata needs to be more specific. Caller-provided `request_context` values use the shared `TextForSpeech.RequestContext` shape, including required `reqPurpose` and optional `prefacePolicy`. 11. Use `delete_voice_profile` only after confirming the exact `profile_name`, especially when multiple similar profiles exist. Ordinary deletion is for user-owned profiles; system-authored built-ins are maintained by SpeakSwiftly's bundled system-profile install and refresh behavior. Drafting guidance: diff --git a/Sources/SpeakSwiftlyServer/MCP/MCPToolCatalog.swift b/Sources/SpeakSwiftlyServer/MCP/MCPToolCatalog.swift index 711fa13..b7b44e3 100644 --- a/Sources/SpeakSwiftlyServer/MCP/MCPToolCatalog.swift +++ b/Sources/SpeakSwiftlyServer/MCP/MCPToolCatalog.swift @@ -5,7 +5,7 @@ enum MCPToolCatalog { static let definitions: [Tool] = [ Tool( name: "generate_speech", - description: "Queue live speech playback with a stored SpeakSwiftly voice profile. Use this when the user wants audible output now. The server fills MCP client and tool provenance in request_context by default; optionally provide profile_name to override the server's configured default voice profile plus text_profile_id, request_context, cwd, and repo_root when the input needs richer caller metadata.", + description: "Queue live speech playback with a stored SpeakSwiftly voice profile. Use this when the user wants audible output now. The server fills MCP client, tool, and request-purpose provenance in request_context by default; optionally provide profile_name to override the server's configured default voice profile plus text_profile_id, request_context, cwd, and repo_root when the input needs richer caller metadata.", inputSchema: [ "type": "object", "required": ["text"], @@ -22,7 +22,7 @@ enum MCPToolCatalog { ), Tool( name: "generate_audio_file", - description: "Queue one retained generated-audio file instead of live playback. Use this when the user wants a saved artifact they can inspect or reuse later. The server fills MCP client and tool provenance in request_context by default; optionally provide profile_name to override the server's configured default voice profile plus request_context when the downstream artifact should retain richer caller metadata.", + description: "Queue one retained generated-audio file instead of live playback. Use this when the user wants a saved artifact they can inspect or reuse later. The server fills MCP client, tool, and request-purpose provenance in request_context by default; optionally provide profile_name to override the server's configured default voice profile plus request_context when the downstream artifact should retain richer caller metadata.", inputSchema: [ "type": "object", "required": ["text"], @@ -38,7 +38,7 @@ enum MCPToolCatalog { ), Tool( name: "generate_batch", - description: "Queue a retained generated-audio batch from multiple items under one voice profile. Use this when the user wants several output files produced together. The server fills MCP client and tool provenance in each item request_context by default; optionally provide profile_name to override the server's configured default voice profile. Each item may carry its own request_context payload.", + description: "Queue a retained generated-audio batch from multiple items under one voice profile. Use this when the user wants several output files produced together. The server fills MCP client, tool, and request-purpose provenance in each item request_context by default; optionally provide profile_name to override the server's configured default voice profile. Each item may carry its own request_context payload.", inputSchema: [ "type": "object", "required": ["items"], diff --git a/Tests/SpeakSwiftlyServerLibraryTests/HTTPWorkflowTests.swift b/Tests/SpeakSwiftlyServerLibraryTests/HTTPWorkflowTests.swift index 7dc62d5..385c422 100644 --- a/Tests/SpeakSwiftlyServerLibraryTests/HTTPWorkflowTests.swift +++ b/Tests/SpeakSwiftlyServerLibraryTests/HTTPWorkflowTests.swift @@ -293,7 +293,7 @@ extension ServerTests { uri: "/speech/live", method: .post, headers: [.contentType: "application/json"], - body: byteBuffer(#"{"text":"Route test","text_profile_id":"swift-docs","request_context":{"source":"http","topic":"route-coverage","attributes":{"caller.app":"SpeakSwiftlyServerLibraryTests","caller.project":"SpeakSwiftlyServer","surface":"http"}},"cwd":"./Sources","repo_root":".","qwen_pre_model_text_chunking":true}"#), + body: byteBuffer(#"{"text":"Route test","text_profile_id":"swift-docs","request_context":{"reqPurpose":"speech","source":"http","topic":"route-coverage","attributes":{"caller.app":"SpeakSwiftlyServerLibraryTests","caller.project":"SpeakSwiftlyServer","surface":"http"}},"cwd":"./Sources","repo_root":".","qwen_pre_model_text_chunking":true}"#), ) let speakJSON = try jsonObject(from: speakResponse.body) let speakJobID = try #require(speakJSON["request_id"] as? String) @@ -307,6 +307,7 @@ extension ServerTests { #expect( queuedSpeechInvocation.requestContext == SpeakSwiftly.RequestContext( + reqPurpose: .speech, source: "http", topic: "route-coverage", cwd: "./Sources", @@ -338,6 +339,7 @@ extension ServerTests { #expect( queuedSpeechFileArtifact.requestContext == SpeakSwiftly.RequestContext( + reqPurpose: .audioFile, source: "http", topic: "retained-audio-file", attributes: [ diff --git a/Tests/SpeakSwiftlyServerLibraryTests/HostStateTests.swift b/Tests/SpeakSwiftlyServerLibraryTests/HostStateTests.swift index 5e46239..ac20dfe 100644 --- a/Tests/SpeakSwiftlyServerLibraryTests/HostStateTests.swift +++ b/Tests/SpeakSwiftlyServerLibraryTests/HostStateTests.swift @@ -690,6 +690,7 @@ extension ServerTests { profileName: "default", textProfileID: "swift-docs", requestContext: .init( + reqPurpose: .speech, source: "embedded-session", topic: "state-actions", cwd: "./Sources", @@ -709,6 +710,7 @@ extension ServerTests { #expect( firstQueuedSpeechInvocation.requestContext == SpeakSwiftly.RequestContext( + reqPurpose: .speech, source: "embedded-session", topic: "state-actions", cwd: "./Sources", diff --git a/Tests/SpeakSwiftlyServerLibraryTests/MCPCatalogRuntimeTests.swift b/Tests/SpeakSwiftlyServerLibraryTests/MCPCatalogRuntimeTests.swift index 03ed3a4..965fd57 100644 --- a/Tests/SpeakSwiftlyServerLibraryTests/MCPCatalogRuntimeTests.swift +++ b/Tests/SpeakSwiftlyServerLibraryTests/MCPCatalogRuntimeTests.swift @@ -15,7 +15,7 @@ extension ServerTests { mcpPOSTRequest( body: mcpCallToolRequestJSON( name: "generate_speech", - argumentsJSON: #"{"text":"Inspect MCP resources","profile_name":"default","text_profile_id":"mcp-text","request_context":{"source":"mcp","topic":"catalog-runtime","attributes":{"caller.app":"SpeakSwiftlyServerLibraryTests","caller.project":"SpeakSwiftlyServer","surface":"mcp"}},"cwd":"./Tests","repo_root":".","qwen_pre_model_text_chunking":true}"#, + argumentsJSON: #"{"text":"Inspect MCP resources","profile_name":"default","text_profile_id":"mcp-text","request_context":{"reqPurpose":"speech","source":"mcp","topic":"catalog-runtime","attributes":{"caller.app":"SpeakSwiftlyServerLibraryTests","caller.project":"SpeakSwiftlyServer","surface":"mcp"}},"cwd":"./Tests","repo_root":".","qwen_pre_model_text_chunking":true}"#, ), sessionID: sessionID, ), @@ -31,6 +31,7 @@ extension ServerTests { #expect( queuedSpeechInvocation.requestContext == SpeakSwiftly.RequestContext( + reqPurpose: .speech, source: "mcp", topic: "catalog-runtime", cwd: "./Tests", @@ -84,6 +85,7 @@ extension ServerTests { #expect( retainedAudioArtifact.requestContext == SpeakSwiftly.RequestContext( + reqPurpose: .audioFile, source: "mcp", topic: "generate_audio_file", attributes: [ diff --git a/docs/releases/v8.0.0-release-notes.md b/docs/releases/v8.0.0-release-notes.md new file mode 100644 index 0000000..3fd6cbe --- /dev/null +++ b/docs/releases/v8.0.0-release-notes.md @@ -0,0 +1,35 @@ +# v8.0.0 Release Notes + +## What Changed + +- Raised the `SpeakSwiftly` dependency floor to `9.0.0`. +- Raised the `TextForSpeech` dependency floor to `0.22.0`. +- Added request-purpose defaults for HTTP and MCP speech routes. +- Defaulted live speech requests to `speech` and retained file or batch requests + to `audioFile`. +- Passed caller-provided `prefacePolicy` through to `TextForSpeech` so clients + can override the source/topic preface policy. +- Updated MCP resources and tool descriptions to document request-purpose + provenance. + +## Breaking Changes + +- Clients that send a nested `request_context` object must include required + `reqPurpose` when they bypass the route/tool defaults. +- The shared request-context shape now follows `TextForSpeech.RequestContext` + from `0.22.0`. + +## Migration Notes + +- Use `reqPurpose: "speech"` for live playback. +- Use `reqPurpose: "audioFile"` for retained audio-file and batch generation. +- Use `reqPurpose: "audioStream"` for stream-oriented callers. +- Omit `prefacePolicy` to follow default behavior, set `"always"` to force the + source/topic preface, or set `"never"` to suppress it. +- Do not send `source_format`; generation text is auto-detected and source-file + requests can be inferred from file extensions. + +## Verification + +- `swift test` +- `sh scripts/repo-maintenance/validate-all.sh` From 94be734e52eca51cd5e412fc4892bd17360aab85 Mon Sep 17 00:00:00 2001 From: Gale W Date: Sat, 9 May 2026 11:23:37 -0400 Subject: [PATCH 2/2] server: preserve purpose only request contexts --- .../Host/ServerModels.swift | 5 ++- .../HostStateTests.swift | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Sources/SpeakSwiftlyServer/Host/ServerModels.swift b/Sources/SpeakSwiftlyServer/Host/ServerModels.swift index 535b4f3..c50965c 100644 --- a/Sources/SpeakSwiftlyServer/Host/ServerModels.swift +++ b/Sources/SpeakSwiftlyServer/Host/ServerModels.swift @@ -46,7 +46,7 @@ func makeSpeechRequestContext( defaults: SpeechRequestContextDefaults = .init(), ) -> SpeakSwiftly.RequestContext? { let merged = SpeakSwiftly.RequestContext( - reqPurpose: defaults.reqPurpose, + reqPurpose: requestContext?.reqPurpose ?? defaults.reqPurpose, source: requestContext?.source ?? defaults.source, topic: requestContext?.topic ?? defaults.topic, cwd: cwd ?? requestContext?.cwd ?? defaults.cwd, @@ -63,6 +63,9 @@ func makeSpeechRequestContext( || merged.cwd != nil || merged.repoRoot != nil || !merged.attributes.isEmpty + || merged.prefacePolicy != nil + || requestContext?.reqPurpose != nil + || defaults.reqPurpose != .speech else { return nil } diff --git a/Tests/SpeakSwiftlyServerLibraryTests/HostStateTests.swift b/Tests/SpeakSwiftlyServerLibraryTests/HostStateTests.swift index ac20dfe..a5eed0b 100644 --- a/Tests/SpeakSwiftlyServerLibraryTests/HostStateTests.swift +++ b/Tests/SpeakSwiftlyServerLibraryTests/HostStateTests.swift @@ -6,6 +6,42 @@ import Testing // MARK: - Host State Tests extension ServerTests { + @Test func `speech request context keeps purpose and preface only contexts`() { + let purposeOnly = makeSpeechRequestContext( + cwd: nil, + repoRoot: nil, + requestContext: nil, + defaults: .init(reqPurpose: .audioFile), + ) + #expect(purposeOnly == SpeakSwiftly.RequestContext(reqPurpose: .audioFile)) + + let callerPurposeOnly = makeSpeechRequestContext( + cwd: nil, + repoRoot: nil, + requestContext: SpeakSwiftly.RequestContext(reqPurpose: .audioStream), + ) + #expect(callerPurposeOnly == SpeakSwiftly.RequestContext(reqPurpose: .audioStream)) + + let prefaceOnly = makeSpeechRequestContext( + cwd: nil, + repoRoot: nil, + requestContext: SpeakSwiftly.RequestContext( + reqPurpose: .speech, + prefacePolicy: .never, + ), + ) + #expect( + prefaceOnly + == SpeakSwiftly.RequestContext( + reqPurpose: .speech, + prefacePolicy: .never, + ), + ) + + let defaultSpeech = makeSpeechRequestContext(cwd: nil, repoRoot: nil, requestContext: nil) + #expect(defaultSpeech == nil) + } + @Test func `shared playback and queue snapshots keep transport response field names`() throws { let activeRequest = ActiveRequestSnapshot(id: "request-1", op: "generate_speech", profileName: "default") let queuedRequest = QueuedRequestSnapshot(