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
8 changes: 8 additions & 0 deletions changelogs/v2.5.8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## Improvements

- **Community services can now bundle several tools**: A single installed service connector can expose multiple related operations (each its own tool) that share one base URL, headers, and API key — instead of needing a separate connector, re-entered key, for every endpoint. Operations support URL path templates (e.g. `/repos/{owner}/{repo}/issues/{number}`) with per-argument placement (path, query, or JSON body) and connector-supplied static query parameters, so a connector can cover a real multi-endpoint API. Existing single-endpoint services keep working unchanged.

## Under the hood

- Extended the shared HTTP-service core (`@cairn/shared/chat/service-exec`) and the community registry schema with multi-operation support: `baseUrl` + `operations[]`, `{placeholder}` path templating, explicit `paramLocations` (path/query/body), and static per-operation `query` params. The desktop `custom_services` store gained `base_url`/`operations` columns (auto-migrated), and the tool builder/executor route each operation through the shared `resolveOperation` + `buildOperationRequest`. Desktop and mobile consume the identical shared core, so a connector behaves the same on both. Backward-compatible: legacy single-op services normalize to a one-operation service internally.
- Hardening from a code review: the registry schema now rejects service-operation `path`s that carry their own scheme/host, so a connector can't redirect a request off its pinned `baseUrl` origin (with new tests); a malformed operation/tool definition now resolves to a clean error string instead of throwing into the tool loop; the resolved-operation identity is derived from the tool definition name so it stays consistent across tool listing, resolution, and execution; and the service-call error message now reports the fully-resolved request URL.
17 changes: 11 additions & 6 deletions electron/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,8 +1094,9 @@ export function deleteMcpServer(db: Database.Database, id: string) {

interface CustomServiceInput {
id: string; workspaceId: string; name: string; description?: string;
apiUrl: string; method: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record<string, string>;
toolDefinition: string; responseKeys?: string[]; apiKeyUrl?: string;
apiUrl?: string; method?: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record<string, string>;
toolDefinition?: string; responseKeys?: string[]; apiKeyUrl?: string;
baseUrl?: string; operations?: unknown[];
authMode?: "none" | "oauth";
oauth?: { serverUrl?: string; scope?: string; clientId?: string; authorizationUrl?: string; tokenUrl?: string };
enabled: boolean; source: string; communityId?: string; version?: string;
Expand All @@ -1114,15 +1115,17 @@ export function getCustomServiceById(db: Database.Database, id: string) {
export function saveCustomService(db: Database.Database, s: CustomServiceInput) {
const now = ts();
db.prepare(`
INSERT INTO custom_services (id, workspace_id, name, description, api_url, method, headers, tool_definition, response_keys, api_key_url, enabled, source, community_id, version, auth_mode, oauth_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO custom_services (id, workspace_id, name, description, api_url, method, headers, tool_definition, base_url, operations, response_keys, api_key_url, enabled, source, community_id, version, auth_mode, oauth_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
api_url = excluded.api_url,
method = excluded.method,
headers = excluded.headers,
tool_definition = excluded.tool_definition,
base_url = excluded.base_url,
operations = excluded.operations,
response_keys = excluded.response_keys,
api_key_url = excluded.api_key_url,
enabled = excluded.enabled,
Expand All @@ -1133,8 +1136,10 @@ export function saveCustomService(db: Database.Database, s: CustomServiceInput)
oauth_config = excluded.oauth_config,
updated_at = excluded.updated_at
`).run(
s.id, s.workspaceId, s.name, s.description ?? null, s.apiUrl, s.method,
j(s.headers ?? {}), s.toolDefinition, j(s.responseKeys ?? []), s.apiKeyUrl ?? null,
s.id, s.workspaceId, s.name, s.description ?? null, s.apiUrl ?? "", s.method ?? "GET",
j(s.headers ?? {}), s.toolDefinition ?? "", s.baseUrl ?? null,
s.operations ? JSON.stringify(s.operations) : null,
j(s.responseKeys ?? []), s.apiKeyUrl ?? null,
s.enabled ? 1 : 0, s.source, s.communityId ?? null, s.version ?? null,
s.authMode ?? "none", s.oauth ? JSON.stringify(s.oauth) : null, now, now,
);
Expand Down
6 changes: 6 additions & 0 deletions electron/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,8 @@ const MIGRATIONS: Migration[] = [
method TEXT NOT NULL DEFAULT 'GET',
headers TEXT NOT NULL DEFAULT '{}',
tool_definition TEXT NOT NULL,
base_url TEXT,
operations TEXT,
response_keys TEXT NOT NULL DEFAULT '[]',
api_key_url TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
Expand Down Expand Up @@ -805,6 +807,10 @@ function ensureColumns(db: Database.Database): void {
ensure("chat_messages", "subagents", "subagents TEXT");
ensure("custom_services", "auth_mode", "auth_mode TEXT NOT NULL DEFAULT 'none'");
ensure("custom_services", "oauth_config", "oauth_config TEXT");
// Multi-operation services: baseUrl + operations[] (JSON). Nullable — legacy
// single-op rows leave them empty and use api_url/method/tool_definition.
ensure("custom_services", "base_url", "base_url TEXT");
ensure("custom_services", "operations", "operations TEXT");
}

function runMigrations(db: Database.Database): void {
Expand Down
148 changes: 12 additions & 136 deletions electron/lib/community-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,57 +17,20 @@

import fs from "fs";
import path from "path";
import * as z from "zod";
import { findUserDataDir } from "../runtime/port-discovery";
import { parseManifest, type CommunityManifest } from "../../shared/chat/registry-schema";

// Manifest TYPES + Zod validation now live in shared/chat/registry-schema.ts so
// desktop and mobile validate the catalog identically. Re-export the types so
// existing electron importers keep resolving them from here.
export type {
CommunityManifest,
RegistryMcpEntry,
RegistryServiceEntry,
RegistryEntryMeta,
} from "../../shared/chat/registry-schema";
export { parseManifest } from "../../shared/chat/registry-schema";

// Local types (main-process side mirrors src/types/index.ts — the two sides are
// wired by IPC channel strings, not a shared import; see preload.ts convention).
export interface CommunityManifest {
version: number;
updatedAt: string;
mcpServers: RegistryMcpEntry[];
services: RegistryServiceEntry[];
}
interface RegistryEntryMeta {
id: string;
author: string;
version: string;
category?: string;
tags: string[];
blurb: string;
brandColor?: string;
homepage?: string;
/** Brand logo, compiled + allowlist-sanitized by cairn-community CI. */
iconSvg?: string;
}
interface RegistryMcpEntry extends RegistryEntryMeta {
definition: {
name: string;
description?: string;
transport: "sse" | "http";
baseUrl: string;
headers?: Record<string, string>;
authMode?: "none" | "oauth";
oauthScope?: string;
disabledTools?: string[];
enabled: boolean;
};
}
interface RegistryServiceEntry extends RegistryEntryMeta {
definition: {
name: string;
description?: string;
apiUrl: string;
method: "GET" | "POST" | "PUT" | "DELETE";
headers?: Record<string, string>;
toolDefinition: string;
responseKeys?: string[];
apiKeyUrl?: string;
authMode?: "none" | "oauth";
oauth?: { serverUrl?: string; scope?: string; clientId?: string; authorizationUrl?: string; tokenUrl?: string };
enabled: boolean;
};
}
export interface RegistryFetchResult {
manifest: CommunityManifest;
fromCache: boolean;
Expand All @@ -80,93 +43,6 @@ const MANIFEST_URL =
const CACHE_FILE = "community-registry.json";
const FETCH_TIMEOUT_MS = 10_000;

// ── validation (mirrors cairn-community/schema.json) ────────────────────────

const headers = z.record(z.string(), z.string()).optional();

const mcpDefinition = z.object({
name: z.string().min(1),
description: z.string().optional(),
transport: z.enum(["sse", "http"]),
baseUrl: z.string().url().startsWith("https://"),
headers,
authMode: z.enum(["none", "oauth"]).optional(),
oauthScope: z.string().optional(),
disabledTools: z.array(z.string()).optional(),
enabled: z.boolean(),
});

const oauthConfig = z
.object({
serverUrl: z.string().url().startsWith("https://").optional(),
scope: z.string().optional(),
clientId: z.string().optional(),
authorizationUrl: z.string().url().startsWith("https://").optional(),
tokenUrl: z.string().url().startsWith("https://").optional(),
})
.optional();

const serviceDefinition = z.object({
name: z.string().min(1),
description: z.string().optional(),
apiUrl: z.string().url().startsWith("https://"),
method: z.enum(["GET", "POST", "PUT", "DELETE"]),
headers,
toolDefinition: z.string().min(1),
responseKeys: z.array(z.string()).optional(),
apiKeyUrl: z.string().url().startsWith("https://").optional(),
authMode: z.enum(["none", "oauth"]).optional(),
oauth: oauthConfig,
enabled: z.boolean(),
});

const entryMeta = {
id: z.string(),
author: z.string(),
version: z.string(),
category: z.string().optional(),
tags: z.array(z.string()),
blurb: z.string(),
brandColor: z.string().optional(),
// Validated as an https URL — it is rendered as an anchor href in the Browse
// modal, so an unvalidated string could smuggle a javascript:/data: URI.
homepage: z.string().url().startsWith("https://").optional(),
// Brand logo, compiled + allowlist-sanitized by cairn-community CI (never raw
// user SVG). Rendered inline by ConnectorLogo. Absent → app fallback glyph.
iconSvg: z.string().optional(),
};

const mcpEntry = z.object({ ...entryMeta, definition: mcpDefinition }).passthrough();
const serviceEntry = z.object({ ...entryMeta, definition: serviceDefinition }).passthrough();

// Manifest-level shape only validates the envelope; entries are validated
// individually in parseManifest so ONE bad community entry can't blank the
// whole catalog (the reject-all behaviour of z.array(z.object(...)) would).
const manifestSchema = z.object({
version: z.number(),
updatedAt: z.string(),
mcpServers: z.array(z.unknown()),
services: z.array(z.unknown()),
});

/** Parse + validate an unknown payload into a CommunityManifest, or throw. */
export function parseManifest(raw: unknown): CommunityManifest {
const m = manifestSchema.parse(raw);
return {
version: m.version,
updatedAt: m.updatedAt,
// Drop malformed entries individually rather than failing the whole parse.
mcpServers: m.mcpServers.flatMap((e) => {
const r = mcpEntry.safeParse(e);
return r.success ? [r.data] : [];
}),
services: m.services.flatMap((e) => {
const r = serviceEntry.safeParse(e);
return r.success ? [r.data] : [];
}),
} as CommunityManifest;
}

// ── cache ───────────────────────────────────────────────────────────────────

interface CacheEnvelope {
Expand Down
2 changes: 1 addition & 1 deletion electron/lib/custom-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe("custom-services namespacing", () => {

describe("custom-services tool definition parsing", () => {
it("parses a bare definition object", () => {
const def = parseToolDefinition(baseCfg.toolDefinition);
const def = parseToolDefinition(baseCfg.toolDefinition!);
expect(def.name).toBe("search");
expect(def.description).toBe("Search things");
expect(def.parameters).toEqual({ type: "object", properties: { q: { type: "string" } } });
Expand Down
Loading