Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions docs/plugin-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,15 @@ it contains `{tool,name}` references to generation tools and may be `[]` for an
operation-only Plugin such as FFmpeg. Model names and referenced tools are unique.
This positive declaration prevents utilities from appearing as generation models.

A companion whose `tools/list` response exposes a live, bounded model catalog may
annotate exactly one required, top-level string property in that generation tool's
input schema with `"x-convax-role": "generation-model-id"`. The marked property
must contain explicit bounded choices (for example, `oneOf` string constants), so
the host can project those choices directly into its generation-model picker and
bind the selected opaque value at execution time. Omit the annotation when the
companion falls back to a free-text model field; the annotation never turns
unbounded provider input into a trusted catalog.

Outputs are `text`, `image`, `video`, or `audio`. `acceptedInputs` may contain only
`reference_image`, `reference_video`, `first_frame`, `last_frame`, `audio`, and
`text`. It describes optional Canvas references; the prompt is always a separate
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/nexus-service/convax-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"id": "nexus-service",
"name": "Nexus · OpenRouter",
"description": "Connects Convax to the Convax Workspace in Nexus for OpenRouter chat and live image-model generation through short-lived Data Tokens.",
"version": "0.3.7",
"version": "0.3.8",
"license": "MIT",
"compatibility": {
"pluginSchema": "convax.plugin/7",
Expand All @@ -13,7 +13,7 @@
"companions": [
{
"command": "convax-nexus-mcp",
"version": "0.3.6",
"version": "0.3.7",
"source": "packages/tools/nexus-mcp",
"targets": [
{
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/nexus-service/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@microvoid/convax-plugin-nexus-service",
"version": "0.3.7",
"version": "0.3.8",
"private": true,
"type": "module",
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/nexus-service/package/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"id": "nexus-service",
"name": "Nexus · OpenRouter",
"description": "Connects Convax to the Convax Workspace in Nexus for OpenRouter chat and live image-model generation through short-lived Data Tokens.",
"version": "0.3.7",
"version": "0.3.8",
"contributes": {
"generation": {
"models": [
Expand Down
9 changes: 9 additions & 0 deletions packages/tools/nexus-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ image-output models populate the host-rendered Nexus image-model control. Image
generation uses the already-metered Chat Completions path and returns only validated
embedded image artifacts to the host. Nexus video endpoints remain unavailable
until the service adds a dedicated video Usage Inspector and quota settlement model.
When the live image catalog is bounded, the companion marks its model field so a
compatible host can present each image model as a direct choice; its unbounded
free-text fallback remains unmarked.

Each image request sends the host operation id as `x-nexus-request-id` for safe
diagnostic correlation. A rejected request exposes only its HTTP status, an
allow-listed Gateway error code, and that locally generated operation id to the MCP
caller. Response-provided identifiers, raw upstream messages, response bodies,
prompts, and tokens never cross that diagnostic boundary.

The companion owns PKCE and the loopback callback, stores the rotating Nexus refresh
grant in a private user file, and exposes only a random loopback Gateway credential to
Expand Down
2 changes: 1 addition & 1 deletion packages/tools/nexus-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@microvoid/convax-nexus-mcp",
"version": "0.3.6",
"version": "0.3.7",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
16 changes: 11 additions & 5 deletions packages/tools/nexus-mcp/src/image-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const maximumTotalImageBytes = 32 * 1024 * 1024;
const maximumImages = 8;
const maximumTrackedOperations = 256;
const modelIdPattern = /^~?[A-Za-z0-9]+(?:[._/:-][A-Za-z0-9]+)*$/u;
const operationIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;

interface TrackedOperation {
fingerprint: string;
Expand Down Expand Up @@ -76,6 +77,7 @@ export class NexusImageGenerator {
const response = await this.client.imageCompletion(
call.model,
call.prompt,
call.operation_id,
signal,
);
if (signal.aborted) throw abortError();
Expand Down Expand Up @@ -150,13 +152,17 @@ function parseGenerationCall(value: unknown): GenerationCall {
if (Buffer.byteLength(prompt, "utf8") > maximumPromptBytes) {
throw new Error("Nexus image prompt is too large");
}
const operationId = trimmedString(
input.operation_id,
"Nexus generation operation id",
128,
);
if (!operationIdPattern.test(operationId)) {
throw new Error("Nexus generation operation id is invalid");
}
return {
model,
operation_id: trimmedString(
input.operation_id,
"Nexus generation operation id",
256,
),
operation_id: operationId,
output: "image",
output_directory: trimmedString(
input.output_directory,
Expand Down
46 changes: 41 additions & 5 deletions packages/tools/nexus-mcp/src/mcp-server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { asRecord, type JsonRpcRequest, type ToolResult } from "./contracts.ts";
import { NexusAuthorization } from "./authorization.ts";
import { NexusCheckoutStore } from "./checkout-store.ts";
import { NexusClient, type NexusClientOptions } from "./nexus-client.ts";
import {
NexusClient,
NexusImageHttpError,
type NexusClientOptions,
} from "./nexus-client.ts";
import { NexusImageGenerator } from "./image-generator.ts";
import { NexusLlmGateway } from "./llm-gateway.ts";
import { NexusPluginService } from "./plugin-service.ts";
Expand All @@ -17,7 +21,12 @@ const emptyInputSchema = {
} as const;

const generationCallProperties = {
operation_id: { maxLength: 256, minLength: 1, type: "string" },
operation_id: {
maxLength: 128,
minLength: 1,
pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
type: "string",
},
output: { const: "image", type: "string" },
output_directory: { maxLength: 4_096, minLength: 1, type: "string" },
prompt: { maxLength: 20_000, minLength: 1, type: "string" },
Expand All @@ -34,6 +43,7 @@ export function imageGenerationTool(
oneOf: models.map(({ id, name }) => ({ const: id, title: name })),
title: "Model",
type: "string",
"x-convax-role": "generation-model-id",
}
: {
description:
Expand Down Expand Up @@ -161,6 +171,28 @@ function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
return input.jsonrpc === "2.0" && typeof input.method === "string";
}

export function publicImageGenerationErrorMessage(error: unknown) {
if (!(error instanceof NexusImageHttpError)) {
return "Nexus image generation failed. Check Nexus before retrying because the upstream task result may be unknown.";
}
const details = [
`HTTP ${error.status}`,
...(error.code === undefined ? [] : [`code ${error.code}`]),
`request id ${error.requestId}`,
].join(", ");
const action =
error.status === 401 || error.status === 403
? "Reconnect Nexus in Services before trying again."
: error.status === 429
? "Check the Nexus quota or Plan before trying again."
: error.code === "metering_unsupported"
? "This image route is not enabled for Nexus metering; contact Nexus support before trying again."
: error.status >= 500
? "Use the request id to review Nexus diagnostics before trying again."
: "Review Nexus Services and choose a currently listed image model before trying again.";
return `Nexus rejected image generation (${details}). ${action}`;
}

export interface NexusMcpServerOptions {
checkouts?: NexusCheckoutStore;
client?: NexusClientOptions;
Expand Down Expand Up @@ -278,7 +310,7 @@ export class NexusMcpServer {
this.#sendResult(value.id, {
capabilities: { tools: {} },
protocolVersion,
serverInfo: { name: "convax-nexus-mcp", version: "0.3.5" },
serverInfo: { name: "convax-nexus-mcp", version: "0.3.7" },
});
return;
}
Expand All @@ -296,6 +328,7 @@ export class NexusMcpServer {
async #callTool(request: JsonRpcRequest & { id: number | string }) {
const controller = new AbortController();
this.#inflight.set(request.id, controller);
let toolName: string | undefined;
try {
const params = asRecord(request.params, "tools/call params");
if (
Expand All @@ -305,6 +338,7 @@ export class NexusMcpServer {
this.#sendError(request.id, -32_602, "Unknown tool");
return;
}
toolName = params.name;
const input = asRecord(params.arguments ?? {}, "tool arguments");
if (emptyTools.has(params.name) && Object.keys(input).length !== 0) {
this.#sendError(
Expand Down Expand Up @@ -361,7 +395,7 @@ export class NexusMcpServer {
content: [{ text: "Nexus service operation completed.", type: "text" }],
structuredContent,
} satisfies ToolResult);
} catch {
} catch (error) {
const cancelled = controller.signal.aborted;
console.error(
cancelled ? "[nexus] request cancelled" : "[nexus] request failed",
Expand All @@ -371,7 +405,9 @@ export class NexusMcpServer {
{
text: cancelled
? "Nexus request was cancelled."
: "Nexus request failed.",
: toolName === "image.generate"
? publicImageGenerationErrorMessage(error)
: "Nexus request failed.",
type: "text",
},
],
Expand Down
Loading