diff --git a/changelogs/v2.5.8.md b/changelogs/v2.5.8.md new file mode 100644 index 00000000..0ce916e0 --- /dev/null +++ b/changelogs/v2.5.8.md @@ -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. diff --git a/electron/db/queries.ts b/electron/db/queries.ts index 27e59b3c..ec40b48e 100644 --- a/electron/db/queries.ts +++ b/electron/db/queries.ts @@ -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; - toolDefinition: string; responseKeys?: string[]; apiKeyUrl?: string; + apiUrl?: string; method?: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record; + 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; @@ -1114,8 +1115,8 @@ 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, @@ -1123,6 +1124,8 @@ export function saveCustomService(db: Database.Database, s: CustomServiceInput) 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, @@ -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, ); diff --git a/electron/db/schema.ts b/electron/db/schema.ts index 34175fc5..0eb6711e 100644 --- a/electron/db/schema.ts +++ b/electron/db/schema.ts @@ -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, @@ -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 { diff --git a/electron/lib/community-registry.ts b/electron/lib/community-registry.ts index 782095a3..9ca505ac 100644 --- a/electron/lib/community-registry.ts +++ b/electron/lib/community-registry.ts @@ -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; - 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; - 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; @@ -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 { diff --git a/electron/lib/custom-services.test.ts b/electron/lib/custom-services.test.ts index 947040fc..b027e1a3 100644 --- a/electron/lib/custom-services.test.ts +++ b/electron/lib/custom-services.test.ts @@ -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" } } }); diff --git a/electron/lib/custom-services.ts b/electron/lib/custom-services.ts index 92cf41ae..8b429dd8 100644 --- a/electron/lib/custom-services.ts +++ b/electron/lib/custom-services.ts @@ -1,263 +1,52 @@ /** - * Custom HTTP Services executor — turns a user-/community-defined HTTP API into - * a single function-calling tool the chat and agent loops can invoke. + * Custom HTTP Services executor (Electron) — network + secret-resolution layer. * - * A service config carries a stringified OpenAI tool definition - * (name/description/parameters), an HTTP method + URL, optional secret headers, - * and an optional `responseKeys` allow-list used to trim the response down to - * the fields the model actually needs (token optimisation — ported from the - * Pinch community `optimize_service.js` semantics). - * - * Request shaping: - * - GET / DELETE → arguments become query-string params. - * - POST / PUT → arguments become a JSON request body. - * - * Tool names are namespaced `svc____` so they never collide - * with built-ins, MCP tools, or each other. + * The PURE request-shaping + tool-definition core now lives in + * `shared/chat/service-exec.ts` so desktop and mobile share one implementation + * (name namespacing, tool-def parsing, arg coercion, request building, response + * filtering, sample args). This module keeps only the IMPURE Electron parts: + * resolving keychain header secrets, injecting an OAuth bearer, and performing + * the network call. * * Main-process only (resolves secrets + performs the network call). */ import { resolveSecrets } from "./secure-store"; - -const NS_PREFIX = "svc__"; -const NS_SEP = "__"; +import { + parseToolDefinition, + resolveOperation, + normalizeOperations, + buildOperationRequest, + filterResponse, + sampleArgsFromSchema, + type CustomServiceRuntimeConfig, + type BearerResolver, +} from "../../shared/chat/service-exec"; + +// Re-export the shared pure surface so existing `import * as services` call +// sites (external-tools.ts, ipc/tools.ts) keep resolving every symbol here. +export { + namespaceServiceTool, + parseServiceToolName, + isServiceToolName, + parseToolDefinition, + serviceToOpenAI, + serviceOperationsToOpenAI, + resolveOperation, + normalizeOperations, + coerceArgs, + buildRequest, + buildOperationRequest, + filterResponse, + sampleArgsFromSchema, + type OpenAIToolDef, + type CustomServiceRuntimeConfig, + type ServiceOperation, + type BearerResolver, +} from "../../shared/chat/service-exec"; const CALL_TIMEOUT_MS = 60_000; -// ── Name namespacing (pure) ────────────────────────────────────────────────── - -export function namespaceServiceTool(serviceId: string, toolName: string): string { - return `${NS_PREFIX}${serviceId}${NS_SEP}${toolName}`; -} - -export function parseServiceToolName( - namespaced: string -): { serviceId: string; toolName: string } | null { - if (!namespaced.startsWith(NS_PREFIX)) return null; - const rest = namespaced.slice(NS_PREFIX.length); - const sep = rest.indexOf(NS_SEP); - if (sep <= 0 || sep >= rest.length - NS_SEP.length) return null; - return { serviceId: rest.slice(0, sep), toolName: rest.slice(sep + NS_SEP.length) }; -} - -export function isServiceToolName(name: string): boolean { - return name.startsWith(NS_PREFIX); -} - -// ── Tool definition (pure) ──────────────────────────────────────────────────── - -export interface OpenAIToolDef { - type: "function"; - function: { name: string; description: string; parameters: Record }; -} - -interface ParsedToolDefinition { - name: string; - description: string; - parameters: Record; -} - -/** - * Parse a service's stored `toolDefinition` JSON into name/description/params. - * Accepts either a bare `{ name, description, parameters }` object or the full - * OpenAI `{ type:"function", function:{…} }` wrapper. Throws on invalid JSON. - */ -export function parseToolDefinition(toolDefinition: string): ParsedToolDefinition { - const raw = JSON.parse(toolDefinition) as Record; - const fn = (raw.function ?? raw) as Record; - return { - name: typeof fn.name === "string" ? fn.name : "call", - description: typeof fn.description === "string" ? fn.description : "", - parameters: - fn.parameters && typeof fn.parameters === "object" - ? (fn.parameters as Record) - : { type: "object", properties: {} }, - }; -} - -// ── Service config (subset of CustomServiceConfig) ─────────────────────────── - -export interface CustomServiceRuntimeConfig { - id: string; - apiUrl: string; - method: "GET" | "POST" | "PUT" | "DELETE"; - headers?: Record; - toolDefinition: string; - responseKeys?: string[]; - /** "oauth" services inject a Bearer token resolved at call time (see resolveBearer). */ - authMode?: "none" | "oauth"; -} - -/** - * Resolves a fresh OAuth bearer for an "oauth" service at call time. Injected by - * the caller (external-tools) rather than imported directly so this module stays - * free of the Electron `shell` dependency pulled in by mcp-oauth, keeping it - * unit-testable in plain Node. Returns null when the service isn't connected or - * a refresh fails, in which case no Authorization header is added. - */ -export type BearerResolver = (serviceId: string) => Promise; - -/** Convert a service config into a namespaced OpenAI function def. */ -export function serviceToOpenAI(cfg: CustomServiceRuntimeConfig): OpenAIToolDef { - const def = parseToolDefinition(cfg.toolDefinition); - return { - type: "function", - function: { - name: namespaceServiceTool(cfg.id, def.name), - description: def.description, - parameters: def.parameters, - }, - }; -} - -// ── Request shaping (pure) ──────────────────────────────────────────────────── - -/** - * Coerce model-supplied argument values to the JSON types declared in the - * tool's parameter schema. Some models (especially smaller ones) emit numbers - * and booleans as quoted strings (e.g. `"10"`, `"true"`) regardless of the - * schema. Strict APIs like Exa reject those with a 400 ("expected number, - * received string"). We walk the schema's top-level `properties` and convert - * each value to its declared type when it is safely coercible; anything not - * described by the schema, or not cleanly coercible, is passed through - * untouched. - */ -export function coerceArgs( - args: Record, - parameters: Record | undefined -): Record { - const props = - parameters && typeof parameters === "object" - ? (parameters.properties as Record | undefined) - : undefined; - if (!props || typeof props !== "object") return { ...args }; - - const out: Record = { ...args }; - for (const [key, value] of Object.entries(args ?? {})) { - const schema = props[key] as Record | undefined; - const declared = schema && typeof schema === "object" ? schema.type : undefined; - out[key] = coerceValue(value, declared); - } - return out; -} - -function coerceValue(value: unknown, declared: unknown): unknown { - if (typeof value !== "string") return value; - const trimmed = value.trim(); - - if (declared === "number" || declared === "integer") { - if (trimmed === "" || !/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(trimmed)) return value; - const n = Number(trimmed); - if (!Number.isFinite(n)) return value; - return declared === "integer" ? Math.trunc(n) : n; - } - if (declared === "boolean") { - if (trimmed === "true") return true; - if (trimmed === "false") return false; - return value; - } - if (declared === "array" || declared === "object") { - // Models sometimes send JSON-encoded strings for structured params. - if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) { - try { - const parsed = JSON.parse(trimmed); - // Only accept the parse if its shape matches the declared type — an - // array string for an "object" param (or vice versa) is a mismatch, so - // fall back to the original value rather than send the wrong shape. - const shapeOk = declared === "array" ? Array.isArray(parsed) : isPlainObject(parsed); - return shapeOk ? parsed : value; - } catch { - return value; - } - } - } - return value; -} - -/** True for a non-null, non-array JSON object. */ -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Build the final URL + fetch init for a call. GET/DELETE put args on the query - * string; POST/PUT put them in a JSON body. Header secret refs are resolved by - * the caller before this runs (kept pure here for testing) — pass already- - * resolved headers. `parameters` is the tool's JSON-schema parameter object, - * used to coerce stringified numbers/booleans the model may have emitted. - */ -export function buildRequest( - cfg: CustomServiceRuntimeConfig, - args: Record, - resolvedHeaders: Record, - parameters?: Record -): { url: string; init: RequestInit } { - const usesQuery = cfg.method === "GET" || cfg.method === "DELETE"; - const url = new URL(cfg.apiUrl); - const headers: Record = { ...resolvedHeaders }; - const coerced = coerceArgs(args ?? {}, parameters); - let body: string | undefined; - - if (usesQuery) { - for (const [k, v] of Object.entries(coerced)) { - if (v === undefined || v === null) continue; - url.searchParams.set(k, typeof v === "object" ? JSON.stringify(v) : String(v)); - } - } else { - body = JSON.stringify(coerced); - if (!hasHeader(headers, "content-type")) headers["Content-Type"] = "application/json"; - } - - return { url: url.toString(), init: { method: cfg.method, headers, body } }; -} - -function hasHeader(headers: Record, name: string): boolean { - const lower = name.toLowerCase(); - return Object.keys(headers).some((k) => k.toLowerCase() === lower); -} - -// ── Response filtering (pure) ───────────────────────────────────────────────── - -/** - * Deep-filter a response value to only the keys in `keys`. Matching is by key - * name at any depth (mirrors the Pinch optimiser): arrays are mapped, objects - * keep matching keys *and* recurse into non-matching object/array values so a - * wanted key nested deeper still surfaces. When `keys` is empty/undefined the - * value is returned unchanged. - */ -export function filterResponse(value: unknown, keys?: string[]): unknown { - if (!keys || keys.length === 0) return value; - const wanted = new Set(keys); - return pick(value, wanted); -} - -function pick(value: unknown, wanted: Set): unknown { - if (Array.isArray(value)) { - return value.map((item) => pick(item, wanted)); - } - if (value && typeof value === "object") { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - if (wanted.has(k)) { - out[k] = v; // keep the whole subtree of an explicitly wanted key - } else if (v && typeof v === "object") { - const nested = pick(v, wanted); - // Only retain the nested container if it yielded something. - if (!isEmptyContainer(nested)) out[k] = nested; - } - } - return out; - } - return value; -} - -function isEmptyContainer(v: unknown): boolean { - if (Array.isArray(v)) return v.length === 0 || v.every(isEmptyContainer); - if (v && typeof v === "object") return Object.keys(v).length === 0; - return false; -} - // ── Execution (impure) ──────────────────────────────────────────────────────── async function fetchWithTimeout(url: string, init: RequestInit, ms: number): Promise { @@ -281,21 +70,24 @@ export async function callService( args: Record, resolveBearer?: BearerResolver, ): Promise { - const parsed = parseServiceToolName(namespaced); - if (!parsed || parsed.serviceId !== cfg.id) { - return `Error: "${namespaced}" is not a tool of service ${cfg.id}`; - } try { + // resolveOperation → normalizeOperations → parseToolDefinition can throw on + // a malformed operation definition; keep it inside the try so it becomes the + // standard error string rather than rejecting into the tool loop. + const op = resolveOperation(cfg, namespaced); + if (!op) { + return `Error: "${namespaced}" is not a tool of service ${cfg.id}`; + } const headers = await withOAuthBearer(cfg, resolveSecrets(cfg.headers ?? {}), resolveBearer); - const { parameters } = parseToolDefinition(cfg.toolDefinition); - const { url, init } = buildRequest(cfg, args, headers, parameters); + const { parameters } = parseToolDefinition(op.toolDefinition); + const { url, init } = buildOperationRequest(op, args, headers, parameters); const res = await fetchWithTimeout(url, init, CALL_TIMEOUT_MS); const text = await res.text(); if (!res.ok) { - return `Error: ${cfg.method} ${cfg.apiUrl} returned ${res.status} ${res.statusText}\n${text.slice(0, 1000)}`; + return `Error: ${op.method} ${url} returned ${res.status} ${res.statusText}\n${text.slice(0, 1000)}`; } const parsedBody = tryParseJson(text); - const filtered = filterResponse(parsedBody, cfg.responseKeys); + const filtered = filterResponse(parsedBody, op.responseKeys); return typeof filtered === "string" ? filtered : JSON.stringify(filtered); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -345,10 +137,16 @@ export async function testService( cfg: CustomServiceRuntimeConfig, sampleArgs?: Record, resolveBearer?: BearerResolver, + /** Which operation to test (namespaced tool name); defaults to the first. */ + namespaced?: string, ): Promise<{ ok: boolean; status?: number; preview?: string; error?: string }> { try { + const op = namespaced + ? resolveOperation(cfg, namespaced) + : normalizeOperations(cfg)[0]; + if (!op) return { ok: false, error: "Service has no operations to test." }; const headers = await withOAuthBearer(cfg, resolveSecrets(cfg.headers ?? {}), resolveBearer); - const { parameters } = parseToolDefinition(cfg.toolDefinition); + const { parameters } = parseToolDefinition(op.toolDefinition); // When the caller doesn't supply args, synthesise a realistic body from the // tool's parameter schema so endpoints with required fields (e.g. a /search // API needing `query`) aren't rejected with a 400 just for being empty. @@ -356,76 +154,13 @@ export async function testService( sampleArgs && Object.keys(sampleArgs).length > 0 ? sampleArgs : sampleArgsFromSchema(parameters); - const { url, init } = buildRequest(cfg, args, headers, parameters); + const { url, init } = buildOperationRequest(op, args, headers, parameters); const res = await fetchWithTimeout(url, init, CALL_TIMEOUT_MS); const text = await res.text(); - const filtered = filterResponse(tryParseJson(text), cfg.responseKeys); + const filtered = filterResponse(tryParseJson(text), op.responseKeys); const preview = (typeof filtered === "string" ? filtered : JSON.stringify(filtered)).slice(0, 500); return { ok: res.ok, status: res.status, preview }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; } } - -/** - * Build a minimal-but-valid sample argument object from a JSON-schema parameter - * definition, used by the Settings "Test connection" button. Populates every - * `required` property (and any property that declares an `example`/`default`) - * with a representative value of the correct type. Best-effort: unknown shapes - * fall back to a sensible primitive. - */ -export function sampleArgsFromSchema(parameters: Record | undefined): Record { - const props = - parameters && typeof parameters === "object" - ? (parameters.properties as Record | undefined) - : undefined; - if (!props || typeof props !== "object") return {}; - - const required = Array.isArray((parameters as Record).required) - ? ((parameters as Record).required as string[]) - : []; - const requiredSet = new Set(required); - - const out: Record = {}; - for (const [key, rawSchema] of Object.entries(props)) { - const schema = (rawSchema && typeof rawSchema === "object" ? rawSchema : {}) as Record; - // Only include required fields (plus those with an explicit example/default) - // so the test request stays minimal and predictable. - const hasHint = schema.example !== undefined || schema.default !== undefined; - if (!requiredSet.has(key) && !hasHint) continue; - out[key] = sampleValue(key, schema); - } - return out; -} - -function sampleValue(key: string, schema: Record): unknown { - // Enum first: a valid enum member is guaranteed to satisfy the schema, whereas - // an example/default could conflict with the enum. Fall back to example/default - // only when there's no usable enum. - if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0]; - if (schema.example !== undefined) return schema.example; - if (schema.default !== undefined) return schema.default; - - switch (schema.type) { - case "number": - case "integer": - return 1; - case "boolean": - return true; - case "array": { - const items = (schema.items && typeof schema.items === "object" ? schema.items : {}) as Record; - return [sampleValue(key, items)]; - } - case "object": { - const nested = schema.properties && typeof schema.properties === "object" - ? sampleArgsFromSchema(schema) - : {}; - return nested; - } - case "string": - default: - // A short, human-readable placeholder. Search APIs validate a non-empty - // query string, so "test" exercises a realistic request. - return "test"; - } -} diff --git a/electron/lib/external-ref.ts b/electron/lib/external-ref.ts index 5b769070..c9c6c3cd 100644 --- a/electron/lib/external-ref.ts +++ b/electron/lib/external-ref.ts @@ -1,230 +1,15 @@ /** - * External-reference extraction for MCP-server / custom-HTTP-service tool - * results. + * Re-export of the shared external-reference extractor. * - * Native Cairn tools (create_note, update_task, …) return a well-known shape, so - * the chat loop can attach a `cairnRef` that renders a clickable chip opening the - * note/task in-app. MCP and custom-service tools instead return arbitrary, - * vendor-specific JSON — but the useful artefact is almost always an external - * URL (a Confluence page, a Jira issue, a web-search hit, a GitHub PR, …). - * - * {@link extractExternalRef} is a best-effort, schema-less heuristic: it scans a - * tool's stringified output for the most linkable thing and returns a normalized - * `{ url, title?, snippet? }` the renderer can show as a browser-opening chip. - * It is intentionally conservative — only http(s) URLs are ever returned, and it - * gives up (returns undefined) rather than guess wildly, so the UI simply falls - * back to the plain "✓ ToolName" chip. - * - * Pure + dependency-free → unit-testable in plain Node. - */ - -export interface ExternalRef { - url: string; - title?: string; - snippet?: string; -} - -/** - * URL-bearing field names, in priority order. `html_url`/`webUrl`/`permalink` - * are the canonical "human" links on GitHub/Graph/Reddit etc.; a bare `url`/ - * `link`/`href` is the common fallback. `self` (Jira/Atlassian REST) is last - * because it is often an API URL rather than a browser one — still better than - * nothing when it is the only link present. - */ -const URL_KEYS = [ - "html_url", - "webUrl", - "web_url", - "permalink", - "browseUrl", - "browse_url", - "url", - "link", - "href", - "self", -] as const; - -/** Title-bearing field names, in priority order. */ -const TITLE_KEYS = ["title", "name", "subject", "summary", "headline", "displayName"] as const; - -/** Snippet/description field names, in priority order. */ -const SNIPPET_KEYS = ["snippet", "description", "excerpt", "abstract", "text", "content"] as const; - -/** Nested containers commonly holding a list of result objects. */ -const LIST_KEYS = ["results", "items", "data", "hits", "matches", "records", "value", "entries"] as const; - -/** Max depth to walk when hunting for a URL, to bound cost on huge payloads. */ -const MAX_DEPTH = 6; - -/** - * Extract a single best external reference from a tool's stringified output. - * Returns undefined when the output isn't JSON, holds no usable http(s) URL, or - * is a Cairn error object. - */ -export function extractExternalRef(output: string | undefined): ExternalRef | undefined { - if (!output || typeof output !== "string") return undefined; - let parsed: unknown; - try { - parsed = JSON.parse(output); - } catch { - // Non-JSON output (plain text). Fall back to the first bare https URL in it. - return refFromLooseText(output); - } - // A Cairn tool error object ({ error: "…" }) carries nothing linkable. - if (isPlainObject(parsed) && typeof parsed.error === "string" && Object.keys(parsed).length === 1) { - return undefined; - } - return findRef(parsed, 0); -} - -/** - * Extract up to `max` external references from a list-shaped result (e.g. a web - * search returning many hits), so the UI can render a compact stack. Falls back - * to the single {@link extractExternalRef} when the payload isn't a list. - */ -export function extractExternalRefs(output: string | undefined, max = 3): ExternalRef[] { - if (!output) return []; - let parsed: unknown; - try { - parsed = JSON.parse(output); - } catch { - const one = refFromLooseText(output); - return one ? [one] : []; - } - const list = findList(parsed, 0); - if (list && list.length > 0) { - const refs: ExternalRef[] = []; - for (const item of list) { - const ref = findRef(item, 0); - if (ref && !refs.some((r) => r.url === ref.url)) refs.push(ref); - if (refs.length >= max) break; - } - if (refs.length > 0) return refs; - } - const single = findRef(parsed, 0); - return single ? [single] : []; -} - -// ── internals ──────────────────────────────────────────────────────────────── - -/** Depth-first search for the first usable URL, preferring shallow/priority keys. */ -function findRef(value: unknown, depth: number): ExternalRef | undefined { - if (depth > MAX_DEPTH) return undefined; - - if (Array.isArray(value)) { - for (const item of value) { - const ref = findRef(item, depth + 1); - if (ref) return ref; - } - return undefined; - } - - if (!isPlainObject(value)) return undefined; - - // 1. A direct URL field on this object (priority order). - const url = firstUrl(value); - if (url) { - return { - url, - title: firstString(value, TITLE_KEYS), - snippet: trimSnippet(firstString(value, SNIPPET_KEYS)), - }; - } - - // 2. Atlassian-style nested links: _links.webui / _links.self / links.html. - // Prefer the human page keys (webui/html) over a generic self/api URL. - const links = (value._links ?? value.links) as unknown; - if (isPlainObject(links)) { - const linkUrl = firstString(links, ["webui", "html"] as const) ?? firstUrl(links); - if (linkUrl && isHttpUrl(linkUrl)) { - return { - url: linkUrl, - title: firstString(value, TITLE_KEYS), - snippet: trimSnippet(firstString(value, SNIPPET_KEYS)), - }; - } - } - - // 3. Recurse into nested objects/arrays (lists first — that's where hits live). - for (const key of LIST_KEYS) { - if (key in value) { - const ref = findRef(value[key], depth + 1); - if (ref) return ref; - } - } - for (const v of Object.values(value)) { - if (isPlainObject(v) || Array.isArray(v)) { - const ref = findRef(v, depth + 1); - if (ref) return ref; - } - } - return undefined; -} - -/** Find the first list of objects under a common container key. */ -function findList(value: unknown, depth: number): unknown[] | undefined { - if (depth > MAX_DEPTH) return undefined; - if (Array.isArray(value)) return value; - if (!isPlainObject(value)) return undefined; - for (const key of LIST_KEYS) { - const v = value[key]; - if (Array.isArray(v) && v.length > 0) return v; - } - for (const v of Object.values(value)) { - if (isPlainObject(v)) { - const nested = findList(v, depth + 1); - if (nested) return nested; - } - } - return undefined; -} - -/** First http(s) URL among the known URL keys of an object. */ -function firstUrl(obj: Record): string | undefined { - for (const key of URL_KEYS) { - const v = obj[key]; - if (typeof v === "string" && isHttpUrl(v)) return v; - } - return undefined; -} - -function firstString(obj: Record, keys: readonly string[]): string | undefined { - for (const key of keys) { - const v = obj[key]; - if (typeof v === "string" && v.trim()) return v.trim(); - } - return undefined; -} - -/** Only http(s) — never javascript:, data:, file:, etc. */ -export function isHttpUrl(value: string): boolean { - if (typeof value !== "string") return false; - let u: URL; - try { - u = new URL(value); - } catch { - return false; - } - return u.protocol === "https:" || u.protocol === "http:"; -} - -const HTTPS_RE = /\bhttps?:\/\/[^\s"'<>)\]]+/; -/** Pull the first bare http(s) URL out of a plain-text (non-JSON) tool result. */ -function refFromLooseText(text: string): ExternalRef | undefined { - const m = HTTPS_RE.exec(text); - if (!m) return undefined; - // Strip common trailing punctuation the regex may have swept up. - const url = m[0].replace(/[.,;:]+$/, ""); - return isHttpUrl(url) ? { url } : undefined; -} - -function trimSnippet(s: string | undefined): string | undefined { - if (!s) return undefined; - const clean = s.replace(/\s+/g, " ").trim(); - if (!clean) return undefined; - return clean.length > 160 ? `${clean.slice(0, 157)}…` : clean; -} - -function isPlainObject(v: unknown): v is Record { - return typeof v === "object" && v !== null && !Array.isArray(v); -} + * The implementation now lives in `shared/chat/external-ref.ts` so both the + * Electron desktop app and the Expo mobile app derive a linkable artefact from + * an MCP/HTTP-service tool result the same way (https-guarded). This thin shim + * keeps the existing `electron/lib/external-ref` import path working. + */ + +export { + extractExternalRef, + extractExternalRefs, + isHttpUrl, + type ExternalRef, +} from "../../shared/chat/external-ref"; diff --git a/electron/lib/external-tools.ts b/electron/lib/external-tools.ts index c91e0913..77202d51 100644 --- a/electron/lib/external-tools.ts +++ b/electron/lib/external-tools.ts @@ -156,20 +156,38 @@ export async function getExternalToolDefs( return []; }); - const svcDefs = customServices.map((s) => - services.serviceToOpenAI({ - id: s.id, - apiUrl: s.apiUrl, - method: s.method, - headers: s.headers, - toolDefinition: s.toolDefinition, - responseKeys: s.responseKeys, - }) + const svcDefs = customServices.flatMap((s) => + services.serviceOperationsToOpenAI(svcToRuntimeConfig(s)) ); return [...mcpDefs, ...svcDefs]; } +/** Build the shared runtime config for a stored custom service (single- or multi-op). */ +function svcToRuntimeConfig(s: { + id: string; + apiUrl?: string; + method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + toolDefinition?: string; + baseUrl?: string; + operations?: unknown[]; + responseKeys?: string[]; + authMode?: "none" | "oauth"; +}): services.CustomServiceRuntimeConfig { + return { + id: s.id, + apiUrl: s.apiUrl, + method: s.method, + headers: s.headers, + toolDefinition: s.toolDefinition, + baseUrl: s.baseUrl, + operations: s.operations as services.ServiceOperation[] | undefined, + responseKeys: s.responseKeys, + authMode: s.authMode, + }; +} + // ── Routing ─────────────────────────────────────────────────────────────────── /** True if a tool name belongs to an external tool (MCP server or service). */ @@ -218,15 +236,7 @@ export async function executeExternalTool( return `Error: service ${svcParsed.serviceId} is not enabled/attached for this project`; } return services.callService( - { - id: svc.id, - apiUrl: svc.apiUrl, - method: svc.method, - headers: svc.headers, - toolDefinition: svc.toolDefinition, - responseKeys: svc.responseKeys, - authMode: svc.authMode, - }, + svcToRuntimeConfig(svc), name, args, makeServiceBearerResolver(svc), diff --git a/electron/lib/mcp-client.ts b/electron/lib/mcp-client.ts index 4b2548d5..d3b9a389 100644 --- a/electron/lib/mcp-client.ts +++ b/electron/lib/mcp-client.ts @@ -24,76 +24,26 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { resolveSecrets } from "./secure-store"; import { isOAuthServer, makeProvider } from "./mcp-oauth"; +// Pure namespacing + shape conversion now live in shared so desktop + mobile +// namespace/convert/stringify identically. Re-exported below for existing +// callers (electron/mcp/tools, agent loop) that import them from this module. +import { + namespaceToolName, + parseToolName, + isMcpToolName, + mcpToolsToOpenAI, + stringifyToolResult, + type OpenAIToolDef, + type McpToolDef, +} from "../../shared/chat/mcp-namespace"; -/** Namespacing prefix so MCP tools never collide with built-ins or each other. */ -const NS_PREFIX = "mcp__"; -const NS_SEP = "__"; +export { namespaceToolName, parseToolName, isMcpToolName, mcpToolsToOpenAI }; +export type { OpenAIToolDef }; const CONNECT_TIMEOUT_MS = 15_000; const CALL_TIMEOUT_MS = 60_000; const IDLE_TIMEOUT_MS = 5 * 60_000; -// ── Pure helpers (unit-testable) ───────────────────────────────────────────── - -/** Build the namespaced tool name exposed to the model. */ -export function namespaceToolName(serverId: string, toolName: string): string { - return `${NS_PREFIX}${serverId}${NS_SEP}${toolName}`; -} - -/** - * Parse a namespaced tool name back into { serverId, toolName }. Returns null if - * the name is not an MCP-namespaced name (so callers can fall through to other - * tool sources). - */ -export function parseToolName(namespaced: string): { serverId: string; toolName: string } | null { - if (!namespaced.startsWith(NS_PREFIX)) return null; - const rest = namespaced.slice(NS_PREFIX.length); - const sep = rest.indexOf(NS_SEP); - if (sep <= 0 || sep >= rest.length - NS_SEP.length) return null; - return { - serverId: rest.slice(0, sep), - toolName: rest.slice(sep + NS_SEP.length), - }; -} - -/** True if a tool name belongs to an MCP server (vs a built-in or service). */ -export function isMcpToolName(name: string): boolean { - return name.startsWith(NS_PREFIX); -} - -/** OpenAI function-calling tool definition shape. */ -export interface OpenAIToolDef { - type: "function"; - function: { - name: string; - description: string; - parameters: Record; - }; -} - -/** Minimal shape of an MCP tool definition as returned by listTools(). */ -interface McpToolDef { - name: string; - description?: string; - inputSchema?: Record; -} - -/** - * Convert a server's MCP tool list into namespaced OpenAI function defs. Tools - * with no input schema get an empty-object schema (the model still must call - * with `{}`). - */ -export function mcpToolsToOpenAI(serverId: string, tools: McpToolDef[]): OpenAIToolDef[] { - return tools.map((t) => ({ - type: "function" as const, - function: { - name: namespaceToolName(serverId, t.name), - description: t.description ?? "", - parameters: t.inputSchema ?? { type: "object", properties: {} }, - }, - })); -} - // ── Server config (subset of McpServerConfig from src/types) ───────────────── export interface McpServerRuntimeConfig { @@ -322,13 +272,3 @@ export async function testConnection( await dispose(cfg.id); } } - -/** Collapse an MCP CallToolResult content array into a single string. */ -function stringifyToolResult(res: unknown): string { - const r = res as { content?: Array<{ type?: string; text?: string }>; isError?: boolean }; - if (!r || !Array.isArray(r.content)) return JSON.stringify(res); - const text = r.content - .map((c) => (c.type === "text" && typeof c.text === "string" ? c.text : JSON.stringify(c))) - .join("\n"); - return r.isError ? `Error: ${text}` : text; -} diff --git a/electron/lib/mcp-oauth.test.ts b/electron/lib/mcp-oauth.test.ts index 8d7b1726..fcdd2038 100644 --- a/electron/lib/mcp-oauth.test.ts +++ b/electron/lib/mcp-oauth.test.ts @@ -82,11 +82,18 @@ describe("parseOAuthCallback", () => { expect(parseOAuthCallback("cairn:///deep/oauth/callback?code=a&state=b")).toBeNull(); }); - it("rejects missing code or state", () => { - expect(parseOAuthCallback("cairn://oauth/callback?code=a")).toBeNull(); + it("rejects missing code", () => { + expect(parseOAuthCallback("cairn://oauth/callback?code=a")).toEqual({ code: "a", state: "" }); expect(parseOAuthCallback("cairn://oauth/callback?state=b")).toBeNull(); }); + it("accepts an empty/missing state (state is optional in OAuth 2.1)", () => { + // Observed with Tavily: the MCP SDK's auth() flow can omit `state`, so the + // provider echoes back `state=` empty. `code` is the only required param. + expect(parseOAuthCallback("cairn://oauth/callback?code=abc&state=")).toEqual({ code: "abc", state: "" }); + expect(parseOAuthCallback("cairn://oauth/callback?code=abc")).toEqual({ code: "abc", state: "" }); + }); + it("rejects non-cairn / garbage", () => { expect(parseOAuthCallback("not a url")).toBeNull(); expect(parseOAuthCallback("")).toBeNull(); diff --git a/electron/lib/mcp-oauth.ts b/electron/lib/mcp-oauth.ts index ba87b120..9cf19b61 100644 --- a/electron/lib/mcp-oauth.ts +++ b/electron/lib/mcp-oauth.ts @@ -40,55 +40,33 @@ import type { import * as secrets from "./secure-store"; import { randomUUID } from "crypto"; import { startLoopbackListener, type LoopbackListener } from "./oauth-loopback"; +// Deep-link callback parsing + the cairn:// constants now live in shared so +// desktop + mobile parse the redirect identically. Re-exported below for +// existing callers that import them from this module. +import { + parseOAuthCallback, + OAUTH_REDIRECT_URI, + DEEP_LINK_SCHEME, + type OAuthCallback, +} from "../../shared/chat/oauth-callback"; + +export { parseOAuthCallback, OAUTH_REDIRECT_URI, DEEP_LINK_SCHEME }; +export type { OAuthCallback }; /** * Default OAuth redirect target — the `cairn://` custom scheme, registered as an * OS protocol in main.ts. Used as a fallback; most providers (Canva, Google, …) * reject custom schemes at `/authorize` and require the loopback redirect * instead, so {@link startServerAuth} prefers loopback by default. + * (Definition + the deep-link parser now live in shared/chat/oauth-callback and + * are re-exported at the top of this file.) */ -export const OAUTH_REDIRECT_URI = "cairn://oauth/callback"; -/** Custom protocol scheme Cairn registers for deep links. */ -export const DEEP_LINK_SCHEME = "cairn"; /** Secret-store keys for the per-server OAuth artefacts. */ const KEY_CLIENT_INFO = "oauth_client"; const KEY_TOKENS = "oauth_tokens"; const KEY_VERIFIER = "oauth_verifier"; -// ── Deep-link parsing (pure, unit-testable) ────────────────────────────────── - -export interface OAuthCallback { - code: string; - state: string; -} - -/** - * Parse a `cairn://oauth/callback?code=…&state=…` deep link. Returns null if the - * URL is not a well-formed OAuth callback (wrong scheme/host, missing params) so - * unrelated deep links are ignored. Tolerant of host vs. path styles - * (`cairn://oauth/callback` and `cairn:///oauth/callback`). - */ -export function parseOAuthCallback(rawUrl: string): OAuthCallback | null { - if (typeof rawUrl !== "string" || !rawUrl.startsWith(`${DEEP_LINK_SCHEME}://`)) return null; - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return null; - } - // Accept only the two documented forms, exactly: - // cairn://oauth/callback → host="oauth" pathname="/callback" → "oauth/callback" - // cairn:///oauth/callback → host="" pathname="/oauth/callback" → "/oauth/callback" - // Reject any deeper route that merely ends with oauth/callback. - const path = `${url.host}${url.pathname}`.replace(/\/+$/, ""); - if (path !== "oauth/callback" && path !== "/oauth/callback") return null; - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - if (!code || !state) return null; - return { code, state }; -} - // ── Provider config ────────────────────────────────────────────────────────── export interface OAuthServerConfig { diff --git a/electron/lib/parse-tool-args.ts b/electron/lib/parse-tool-args.ts index 7e85af74..6ee06b95 100644 --- a/electron/lib/parse-tool-args.ts +++ b/electron/lib/parse-tool-args.ts @@ -1,148 +1,10 @@ /** - * Tolerant parser for LLM-emitted tool-call `arguments` JSON. + * Re-export of the shared tolerant tool-args parser. * - * The streamed `tool_calls[].function.arguments` string is supposed to be strict - * JSON, but models frequently emit *nearly*-valid JSON — especially for tools - * like `ensure_note`/`patch_note` whose `content` field carries markdown. - * - * `parseToolArgs` first attempts a strict `JSON.parse`. If that fails it applies - * a small set of repairs and retries. On total failure it NEVER silently returns - * `{}`: callers must surface a parse error so the model can re-issue the call. - * - * ── Repair safety contract (critical) ──────────────────────────────────────── - * Field VALUES — above all `content` — are user/model data and MUST round-trip - * byte-identically. A repair is only permitted if it is *provably lossless* for - * every string value. We therefore only repair things that are unambiguously - * malformed JSON *structure*, never anything that could alter the decoded string: - * - * 1. Unescaped control characters INSIDE a string literal (a real newline/tab - * typed straight into `"content":"line1line2"` instead of `\n`). This is - * illegal JSON (RFC 8259 §7), and escaping it to `\n`/`\t`/… decodes back to - * the byte-identical character — lossless. This is the dominant note-write - * breakage. - * 2. Trailing commas before a closing `}`/`]`, detected OUTSIDE string context - * so a literal comma inside content (e.g. "a,}") is never touched. - * - * Explicitly NOT repaired, because they would mutate content: - * - Stripping a wrapping ```code fence``` — indistinguishable from content that - * legitimately begins and ends with a fence; stripping would corrupt the note. - * - Rewriting smart/curly quotes to ASCII — the curly quotes may be genuine - * content ("AI theater"); rewriting them corrupts the note. - * If the JSON is broken in a way these two safe repairs can't fix, we fail loudly - * rather than guess and risk silently changing what the user's note says. - */ - -export type ParseToolArgsResult = - | { ok: true; value: Record; repaired: boolean } - | { ok: false; error: string }; - -/** - * Single context-aware repair pass. Walks the text tracking string context and - * escape state, applying ONLY the two lossless repairs above: - * - inside a string: escape raw control chars (< U+0020) to their JSON escape. - * - outside a string: drop a comma that is followed only by whitespace and then - * a `}` or `]` (a trailing comma). - * - * Because we never alter the decoded text of any string value, `JSON.parse` of - * the result yields byte-identical string values to what the model intended. + * The implementation now lives in `shared/chat/parse-tool-args.ts` so both the + * Electron desktop app and the Expo mobile app parse LLM tool-call arguments the + * same way (strict-first, lossless-repair, never a silent `{}`). This thin + * shim keeps the existing `electron/lib/parse-tool-args` import path working. */ -function repairJson(src: string): string { - let out = ""; - let inString = false; - let escaped = false; - - for (let i = 0; i < src.length; i++) { - const ch = src[i]; - - if (inString) { - if (escaped) { - // Part of an escape sequence — pass through verbatim. - out += ch; - escaped = false; - continue; - } - if (ch === "\\") { - out += ch; - escaped = true; - continue; - } - if (ch === '"') { - out += ch; - inString = false; - continue; - } - const code = src.charCodeAt(i); - if (code < 0x20) { - // Raw control char inside a string → escape (lossless). - if (ch === "\n") out += "\\n"; - else if (ch === "\t") out += "\\t"; - else if (ch === "\r") out += "\\r"; - else if (ch === "\b") out += "\\b"; - else if (ch === "\f") out += "\\f"; - else out += "\\u" + code.toString(16).padStart(4, "0"); - continue; - } - out += ch; - continue; - } - - // ── Outside a string ── - if (ch === '"') { - inString = true; - out += ch; - continue; - } - if (ch === ",") { - // Look ahead past whitespace: if the next non-space char is } or ], this is - // a trailing comma → drop it. Structural-only; string content is unreachable - // here because commas inside strings are handled by the inString branch. - let j = i + 1; - while (j < src.length && (src[j] === " " || src[j] === "\t" || src[j] === "\n" || src[j] === "\r")) j++; - if (j < src.length && (src[j] === "}" || src[j] === "]")) { - continue; // skip the comma - } - out += ch; - continue; - } - out += ch; - } - - return out; -} - -/** - * Parse LLM tool-call arguments with a strict-first, repair-fallback strategy. - * - * @param raw The assembled `function.arguments` string (may be empty/whitespace). - * @returns A discriminated result. `repaired: true` means the strict parse failed - * but the lossless repair pass succeeded — useful for telemetry. - */ -export function parseToolArgs(raw: string | null | undefined): ParseToolArgsResult { - const trimmed = (raw ?? "").trim(); - // Empty args is a legitimate no-arg tool call. - if (trimmed === "") return { ok: true, value: {}, repaired: false }; - - // 1. Strict parse — the happy path for well-behaved models. Well-formed JSON - // (including content that contains code fences, quotes, etc.) succeeds here - // untouched, so nothing is ever mutated on the common path. - try { - const value = JSON.parse(trimmed); - if (value && typeof value === "object" && !Array.isArray(value)) { - return { ok: true, value: value as Record, repaired: false }; - } - return { ok: false, error: `tool arguments must be a JSON object, got ${Array.isArray(value) ? "array" : typeof value}` }; - } catch (strictErr) { - // 2. Lossless repair pass — only fixes malformed JSON structure, never the - // decoded content of any string value. - try { - const value = JSON.parse(repairJson(trimmed)); - if (value && typeof value === "object" && !Array.isArray(value)) { - return { ok: true, value: value as Record, repaired: true }; - } - } catch { - /* fall through to structured failure */ - } - return { ok: false, error: `malformed tool-call arguments JSON from model: ${(strictErr as Error).message}` }; - } -} +export { parseToolArgs, type ParseToolArgsResult } from "../../shared/chat/parse-tool-args"; diff --git a/electron/shared/db-mappers.ts b/electron/shared/db-mappers.ts index 217770fc..4f170c44 100644 --- a/electron/shared/db-mappers.ts +++ b/electron/shared/db-mappers.ts @@ -142,6 +142,8 @@ export function toCustomService(row: any) { method: (row.method ?? "GET") as "GET" | "POST" | "PUT" | "DELETE", headers: pObj(row.headers), toolDefinition: row.tool_definition as string, + baseUrl: (row.base_url ?? undefined) as string | undefined, + operations: row.operations ? (j2(row.operations) as unknown[]) : undefined, responseKeys: j2(row.response_keys), apiKeyUrl: (row.api_key_url ?? undefined) as string | undefined, authMode: (row.auth_mode ?? "none") as "none" | "oauth", diff --git a/mobile/app.json b/mobile/app.json index c0c63582..0454a313 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -64,7 +64,8 @@ } } ], - "expo-status-bar" + "expo-status-bar", + "expo-web-browser" ], "experiments": { "typedRoutes": true diff --git a/mobile/app/(tabs)/chat/index.tsx b/mobile/app/(tabs)/chat/index.tsx index 56d00202..0794dda7 100644 --- a/mobile/app/(tabs)/chat/index.tsx +++ b/mobile/app/(tabs)/chat/index.tsx @@ -24,6 +24,7 @@ import { resetAppleSession } from "@/chat/providers/apple"; import type { UIMessage, ChatUsage } from "@/chat/providers/types"; import { ContextRing } from "@/components/ContextRing"; import { toolRef } from "@cairn/shared/chat/tool-ref"; +import { extractExternalRef } from "@cairn/shared/chat/external-ref"; import { loadInitialChat, type UiMessage } from "@/chat/history"; import { useChatScroll } from "@/chat/useChatScroll"; import { MessageBubble } from "@/components/chat/MessageBubble"; @@ -137,9 +138,26 @@ export default function ChatScreen() { } else if (e.type === "reasoning-delta" && e.delta) { reasoningAcc += e.delta; patchAssistant({ reasoning: reasoningAcc }); + } else if (e.type === "tool-start" && e.tool) { + // Show a "running" chip immediately so slow tools (MCP / web search) + // aren't invisible until they finish. + toolTrail.push({ tool: e.tool, ok: true, id: e.toolCallId, running: true }); + patchAssistant({ tools: [...toolTrail] }); + haptics.impact(); // agent started a tool } else if (e.type === "tool" && e.tool) { const ok = !(e.result && typeof e.result === "object" && "error" in (e.result as object)); - toolTrail.push({ tool: e.tool, ok, ref: toolRef(e.tool, e.args, e.result) }); + // A note/card ref opens in-app; otherwise try for a linkable external + // URL (web-search hit, docs page, …) the chip can open in the browser. + const ref = toolRef(e.tool, e.args, e.result); + const externalRef = ref + ? undefined + : extractExternalRef(typeof e.result === "string" ? e.result : JSON.stringify(e.result ?? null)); + // Finalize the matching "running" chip in place (by tool-call id); + // fall back to appending if no start was seen (provider-ran tools). + const idx = e.toolCallId ? toolTrail.findIndex((c) => c.id === e.toolCallId && c.running) : -1; + const finalized = { tool: e.tool, ok, id: e.toolCallId, running: false, ref, externalRef }; + if (idx >= 0) toolTrail[idx] = finalized; + else toolTrail.push(finalized); patchAssistant({ tools: [...toolTrail] }); haptics.impact(); // agent ran a tool } else if (e.type === "final" && e.usage) { diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 0d53cd6d..2e3d7680 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,4 +1,8 @@ import { useEffect, useState } from "react"; +// Install the WebCrypto shim (crypto.getRandomValues + subtle.digest via +// expo-crypto) BEFORE anything else, so the MCP OAuth PKCE path has it. Side- +// effect import; must stay at the very top. +import "@/chat/crypto-polyfill"; import { Stack, ThemeProvider, DarkTheme, DefaultTheme } from "expo-router"; import { View, Text, ActivityIndicator, StyleSheet, useColorScheme } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -141,6 +145,7 @@ export default function RootLayout() { + diff --git a/mobile/app/settings/tools.tsx b/mobile/app/settings/tools.tsx new file mode 100644 index 00000000..2ae3eba9 --- /dev/null +++ b/mobile/app/settings/tools.tsx @@ -0,0 +1,1041 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + View, + Text, + Pressable, + ScrollView, + Switch, + ActivityIndicator, + Alert, + StyleSheet, + Linking, + Platform, + Modal, + TextInput, +} from "react-native"; +import { Stack, useRouter } from "expo-router"; +import { RefreshCw, Plus, Trash2, ExternalLink, Wrench, ChevronRight, ChevronDown, LogIn, LogOut } from "lucide-react-native"; +import { useTheme, type as typeScale, type Theme } from "@/theme"; +import { haptics, toolbarPress } from "@/haptics"; +import { ICON_CHECK } from "@/components/toolbar-icons"; +import { fetchManifest, getCachedManifest } from "@/chat/registry"; +import { + listInstalledServices, + installService, + uninstallService, + isServiceInstalled, + serviceOperationDefs, + type InstalledService, +} from "@/chat/services"; +import { + listInstalledMcpServers, + installMcpServer, + uninstallMcpServer, + isMcpServerInstalled, + refreshServerTools, + getCachedMcpToolDefsForServer, + type InstalledMcpServer, +} from "@/chat/mcp-store"; +import { startAuth, signOut, hasTokens, type OAuthServerConfig } from "@/chat/mcp-oauth"; +import { TOOLS, WRITE_TOOL_NAMES, type ToolDef } from "@/chat/tools"; +import { getToggleMap, setToolEnabled } from "@/chat/tool-toggles"; +import type { RegistryServiceEntry, RegistryMcpEntry } from "@cairn/shared/chat/registry-schema"; +import { ConnectorLogo } from "@/components/ConnectorLogo"; +import { SearchField } from "@/components/SearchField"; + +/** + * Does a browsable registry entry match the search query? Mirrors desktop's + * BrowseCommunityModal filter: name + blurb + category + tags (tags feed search + * only, never shown as chips). `q` must already be lowercased + trimmed. + */ +function entryMatchesQuery( + entry: { blurb: string; category?: string; tags: string[]; definition: { name: string } }, + q: string, +): boolean { + if (!q) return true; + return ( + entry.definition.name.toLowerCase().includes(q) || + entry.blurb.toLowerCase().includes(q) || + (entry.category ?? "").toLowerCase().includes(q) || + entry.tags.some((tag) => tag.toLowerCase().includes(q)) + ); +} + +/** + * Tools & Services settings — the mobile side of the community registry (Track + * 2). All DEVICE-GLOBAL (they apply across every workspace) and unsynced: + * + * 1. Installed services — HTTP `service` connectors the user has added, each + * with an on/off toggle + uninstall. Their API keys live in the keychain. + * These are the ONLY toggleable tools — matching desktop, which gates only + * external MCP/HTTP tools, never Cairn's own. + * 2. Built-in tools — a READ-ONLY, collapsed-by-default disclosure that + * shows what the assistant can do, split into Read vs Write. No switches: + * built-ins are always on (turning off e.g. search would silently break the + * assistant), so this is purely informational. + * 3. Browse community — a search box + category chips (mirroring desktop's + * Browse Community modal) that filter both the installable services and MCP + * servers below. Freeform tags feed search only; the chips are the fixed + * category vocabulary and shrink as you install things. + * 4. Add a service — the catalog of installable services (authMode:none + * only for now; OAuth services are shown but not yet installable). + * 5. Add an MCP server — installable MCP servers (OAuth via deep-link). + */ + +// Split the always-on built-in tools into Read vs Write for the disclosure. +const READ_BUILTINS: ToolDef[] = TOOLS.filter((t) => !WRITE_TOOL_NAMES.has(t.name)); +const WRITE_BUILTINS: ToolDef[] = TOOLS.filter((t) => WRITE_TOOL_NAMES.has(t.name)); + +/** Map an installed OAuth MCP server to the OAuth flow's server config. */ +function oauthCfg(s: InstalledMcpServer): OAuthServerConfig { + return { id: s.id, serverUrl: s.baseUrl, scope: s.oauthScope }; +} + +export default function ToolsSettingsScreen() { + const t = useTheme(); + const styles = useMemo(() => makeStyles(t), [t]); + const router = useRouter(); + + const [installed, setInstalled] = useState(() => listInstalledServices()); + const [installedMcp, setInstalledMcp] = useState(() => listInstalledMcpServers()); + const [services, setServices] = useState(() => getCachedManifest()?.services ?? []); + const [mcpEntries, setMcpEntries] = useState(() => getCachedManifest()?.mcpServers ?? []); + const [toggles, setToggles] = useState>(() => getToggleMap()); + const [connected, setConnected] = useState>({}); + const [busyId, setBusyId] = useState(null); + // Start un-blocked when we already have a cached catalog: render it instantly + // and let loadRegistry refresh in the background. Only show the full-screen + // spinner on a cold start (nothing cached yet). + const [loading, setLoading] = useState(() => getCachedManifest() === null); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + // Built-in tools are collapsed by default so the screen stays compact — the + // list is long and informational, not something you act on often. + const [builtinsOpen, setBuiltinsOpen] = useState(false); + // Which installed MCP server's tool list is expanded (one at a time). + const [expandedMcp, setExpandedMcp] = useState(null); + // Which installed multi-op service's tool list is expanded (one at a time). + const [expandedService, setExpandedService] = useState(null); + // Browse filters (services + MCP catalog) — mirror desktop's search + category + // chips. Freeform tags feed search only; the chips are the fixed category set. + const [browseQuery, setBrowseQuery] = useState(""); + const [browseCategory, setBrowseCategory] = useState(null); + // Android has no Alert.prompt, so key-based installs use an in-app modal. iOS + // keeps the native prompt. `keyPrompt` holds the entry being keyed + the typed + // value; null when closed. + const [keyPrompt, setKeyPrompt] = useState<{ entry: RegistryServiceEntry; value: string } | null>(null); + + const reloadLocal = useCallback(() => { + setInstalled(listInstalledServices()); + setInstalledMcp(listInstalledMcpServers()); + setToggles(getToggleMap()); + }, []); + + // Refresh the per-OAuth-server connected (has-tokens) map for the badges. + const refreshConnected = useCallback(async () => { + const servers = listInstalledMcpServers().filter((s) => s.authMode === "oauth"); + const entries = await Promise.all(servers.map(async (s) => [s.id, await hasTokens(s.id)] as const)); + setConnected(Object.fromEntries(entries)); + }, []); + + const loadRegistry = useCallback( + async (force: boolean) => { + const { manifest, error: err } = await fetchManifest(force); + setServices(manifest?.services ?? []); + setMcpEntries(manifest?.mcpServers ?? []); + setError(err ?? null); + }, + [], + ); + + useEffect(() => { + // Local + cached state is seeded via lazy useState initializers above. The + // network refresh's setState all happens AFTER an await inside loadRegistry + // (and in this .finally callback), so nothing runs synchronously in the + // effect body. + let cancelled = false; + // setState here only runs inside the async .finally callback (after an await + // boundary the rule's static trace can't follow), never synchronously in the + // effect body — the idiomatic on-mount fetch used across the app. + // eslint-disable-next-line react-hooks/set-state-in-effect + loadRegistry(false).finally(() => { + if (!cancelled) setLoading(false); + }); + void refreshConnected(); + return () => { + cancelled = true; + }; + }, [loadRegistry, refreshConnected]); + + const onRefresh = useCallback(() => { + setRefreshing(true); + haptics.selection(); + void loadRegistry(true).finally(() => setRefreshing(false)); + }, [loadRegistry]); + + const onToggleService = useCallback((name: string, next: boolean) => { + haptics.selection(); + setToolEnabled(name, next); + setToggles(getToggleMap()); + }, []); + + // ── MCP servers ───────────────────────────────────────────────────────────── + + /** Sign in to an OAuth MCP server (deep-link browser flow), then cache tools. */ + const onConnect = useCallback( + async (s: InstalledMcpServer) => { + setBusyId(s.id); + try { + const res = await startAuth(oauthCfg(s), s.name); + if (res.status === "error") { + Alert.alert(res.desktopOnly ? "Connect on desktop" : "Sign-in failed", res.error); + } else if (res.status === "authorized") { + haptics.success(); + await refreshServerTools(s.id).catch(() => null); + } + // "cancelled" → no-op. + } finally { + setBusyId(null); + await refreshConnected(); + } + }, + [refreshConnected], + ); + + const onSignOut = useCallback( + (s: InstalledMcpServer) => { + Alert.alert(`Sign out of ${s.name}?`, "Cairn will forget this server's tokens on this device.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Sign out", + style: "destructive", + onPress: async () => { + await signOut(s.id); + haptics.success(); + await refreshConnected(); + }, + }, + ]); + }, + [refreshConnected], + ); + + /** Install an MCP server from the registry; auto-start OAuth for oauth servers. */ + const onInstallMcp = useCallback( + async (entry: RegistryMcpEntry) => { + if (entry.definition.transport !== "http") { + Alert.alert("Not supported", "Only streamable-HTTP MCP servers work on mobile right now."); + return; + } + installMcpServer(entry); + reloadLocal(); + const server = listInstalledMcpServers().find((s) => s.id === entry.id); + if (server && server.authMode === "oauth") { + await onConnect(server); + } else if (server) { + // No-auth (or API-key header) server: discover tools immediately. + setBusyId(server.id); + try { + await refreshServerTools(server.id).catch(() => null); + } finally { + setBusyId(null); + } + } + }, + [reloadLocal, onConnect], + ); + + const onUninstallMcp = useCallback( + (s: InstalledMcpServer) => { + Alert.alert(`Remove ${s.name}?`, "This disconnects the server and forgets its tokens on this device.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: async () => { + await uninstallMcpServer(s.id); + haptics.success(); + reloadLocal(); + await refreshConnected(); + }, + }, + ]); + }, + [reloadLocal, refreshConnected], + ); + + /** Manually re-discover a server's tools (refresh the cached list). */ + const onRefreshServer = useCallback(async (id: string) => { + setBusyId(`refresh:${id}`); + try { + await refreshServerTools(id).catch(() => null); + // Nudge a re-render so the freshly-cached tools appear. + setToggles(getToggleMap()); + } finally { + setBusyId(null); + } + }, []); + + // Perform the install with a (possibly empty) API key, surfacing failures. + const installWithKey = useCallback( + async (entry: RegistryServiceEntry, apiKey: string) => { + const res = await installService(entry, apiKey); + if (!res.ok) { + Alert.alert("Couldn't install", res.error ?? "Unknown error."); + return; + } + haptics.success(); + reloadLocal(); + }, + [reloadLocal], + ); + + const onInstall = useCallback( + (entry: RegistryServiceEntry) => { + if (entry.definition.authMode === "oauth") { + Alert.alert("Not yet supported", "OAuth services can't be installed on mobile yet — coming soon."); + return; + } + const needsKey = JSON.stringify(entry.definition.headers ?? {}).includes(""); + if (!needsKey) { + void installWithKey(entry, ""); + return; + } + const message = entry.definition.apiKeyUrl + ? `Paste your API key. Get one at ${entry.definition.apiKeyUrl}.` + : "Paste your API key for this service."; + // Alert.prompt is iOS-only; Android falls back to an in-app modal. + if (Platform.OS === "ios") { + Alert.prompt( + `Add ${entry.definition.name}`, + message, + [ + { text: "Cancel", style: "cancel" }, + { text: "Install", onPress: (v?: string) => void installWithKey(entry, v ?? "") }, + ], + "plain-text", + ); + } else { + setKeyPrompt({ entry, value: "" }); + } + }, + [installWithKey], + ); + + const onUninstall = useCallback( + (svc: InstalledService) => { + Alert.alert(`Remove ${svc.name}?`, "Its API key will be deleted from this device.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: async () => { + await uninstallService(svc.id); + haptics.success(); + reloadLocal(); + }, + }, + ]); + }, + [reloadLocal], + ); + + // Registry entries not already installed (installed ones move to section 1), + // then narrowed by the search query + category chip. + const notInstalledServices = useMemo( + () => services.filter((s) => !isServiceInstalled(s.id)), + [services, installed], // eslint-disable-line react-hooks/exhaustive-deps + ); + const notInstalledMcp = useMemo( + () => mcpEntries.filter((s) => !isMcpServerInstalled(s.id)), + [mcpEntries, installedMcp], // eslint-disable-line react-hooks/exhaustive-deps + ); + + // Category chips are the union of categories present across everything still + // installable (services + MCP), so the chip set shrinks as you install things. + const browseCategories = useMemo(() => { + const set = new Set(); + for (const e of [...notInstalledServices, ...notInstalledMcp]) { + if (e.category) set.add(e.category); + } + return [...set].sort(); + }, [notInstalledServices, notInstalledMcp]); + + const q = browseQuery.trim().toLowerCase(); + const browsable = useMemo( + () => + notInstalledServices.filter( + (s) => (!browseCategory || s.category === browseCategory) && entryMatchesQuery(s, q), + ), + [notInstalledServices, browseCategory, q], + ); + const browsableMcp = useMemo( + () => + notInstalledMcp.filter( + (s) => (!browseCategory || s.category === browseCategory) && entryMatchesQuery(s, q), + ), + [notInstalledMcp, browseCategory, q], + ); + // Are there any installable entries at all (before search/category narrowing)? + const hasAnyBrowsable = notInstalledServices.length > 0 || notInstalledMcp.length > 0; + + const renderBuiltinGroup = (label: string, tools: ToolDef[]) => ( + + {label} + {tools.map((tool) => ( + + {tool.name} + {tool.description} + + ))} + + ); + + return ( + + + + toolbarPress(() => router.canGoBack() && router.back())()} + /> + + + {loading ? ( + + + + ) : ( + + {/* 1. Installed services — expandable per-operation tool toggles */} + {installed.length > 0 && ( + + Installed services + {installed.map((svc) => { + const ops = serviceOperationDefs(svc); + const open = expandedService === svc.id; + // A single-operation service shows its lone toggle inline; a + // multi-operation one gets an expandable tool list like MCP. + const multi = ops.length > 1; + return ( + + + + + {svc.name} + {!!svc.blurb && {svc.blurb}} + + {!multi && ops[0] && ( + onToggleService(ops[0].name, v)} + trackColor={{ true: t.accent, false: t.border }} + /> + )} + onUninstall(svc)} + hitSlop={8} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={`Remove ${svc.name}`} + > + + + + + {multi && ( + <> + { + haptics.selection(); + setExpandedService((cur) => (cur === svc.id ? null : svc.id)); + }} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={`${open ? "Collapse" : "Expand"} ${svc.name} tools`} + > + {open ? ( + + ) : ( + + )} + {ops.length} tools + + {open && + ops.map((op) => { + const short = op.name.split("__").slice(2).join("__") || op.name; + return ( + + + {short} + {!!op.description && ( + {op.description} + )} + + onToggleService(op.name, v)} + trackColor={{ true: t.accent, false: t.border }} + /> + + ); + })} + + )} + + ); + })} + + )} + + {/* 1b. Installed MCP servers (connect/sign-out for OAuth; uninstall) */} + {installedMcp.length > 0 && ( + + MCP servers + {installedMcp.map((s) => { + const isOAuth = s.authMode === "oauth"; + const isConnected = connected[s.id] === true; + const busy = busyId === s.id; + const tools = getCachedMcpToolDefsForServer(s.id); + const open = expandedMcp === s.id; + return ( + + + + + {s.name} + {!!s.blurb && {s.blurb}} + {isOAuth && ( + + {isConnected ? "Connected" : "Not connected"} + + )} + + {busy ? ( + + ) : isOAuth ? ( + (isConnected ? onSignOut(s) : void onConnect(s))} + style={styles.iconBtn} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={isConnected ? `Sign out of ${s.name}` : `Connect to ${s.name}`} + > + {isConnected ? ( + + ) : ( + + )} + + ) : null} + onUninstallMcp(s)} + hitSlop={8} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={`Remove ${s.name}`} + > + + + + + {/* Per-server tool manager: expand to toggle individual tools. */} + + { + haptics.selection(); + setExpandedMcp((cur) => (cur === s.id ? null : s.id)); + }} + hitSlop={6} + accessibilityRole="button" + accessibilityLabel={`${open ? "Collapse" : "Expand"} ${s.name} tools`} + > + {open ? ( + + ) : ( + + )} + + {tools.length === 0 ? "No tools discovered yet" : `${tools.length} tools`} + + + void onRefreshServer(s.id)} + hitSlop={8} + disabled={busyId === `refresh:${s.id}`} + style={styles.iconBtn} + accessibilityRole="button" + accessibilityLabel={`Refresh ${s.name} tools`} + > + {busyId === `refresh:${s.id}` ? ( + + ) : ( + + )} + + + + {open && + tools.map((def) => { + const name = def.function.name; + // Show the un-namespaced tool name (after mcp____). + const short = name.split("__").slice(2).join("__") || name; + return ( + + + {short} + {!!def.function.description && ( + + {def.function.description} + + )} + + onToggleService(name, v)} + trackColor={{ true: t.accent, false: t.border }} + /> + + ); + })} + + ); + })} + + )} + + {/* 2. Built-in tools — read-only, collapsed-by-default disclosure */} + + { + haptics.selection(); + setBuiltinsOpen((o) => !o); + }} + accessibilityRole="button" + accessibilityLabel={`${builtinsOpen ? "Collapse" : "Expand"} built-in tools`} + > + {builtinsOpen ? ( + + ) : ( + + )} + Built-in tools + {TOOLS.length} + + {builtinsOpen && ( + <> + + What the assistant can do with your notes and tasks. Always on. + + {renderBuiltinGroup("Read", READ_BUILTINS)} + {renderBuiltinGroup("Write", WRITE_BUILTINS)} + + )} + + + {/* 3. Browse filters — search + category chips (govern both Add lists) */} + {hasAnyBrowsable && ( + + + Browse community + + + + + + {browseCategories.length > 0 && ( + + { + haptics.selection(); + setBrowseCategory(null); + }} + styles={styles} + /> + {browseCategories.map((cat) => ( + { + haptics.selection(); + setBrowseCategory((cur) => (cur === cat ? null : cat)); + }} + styles={styles} + /> + ))} + + )} + + )} + + {/* 3b. Add a service */} + + Services + {error && {error}} + {browsable.length === 0 && !error ? ( + + {!hasAnyBrowsable + ? "Nothing new to add — you've installed everything available." + : "No services match your search."} + + ) : ( + browsable.map((entry) => { + const oauth = entry.definition.authMode === "oauth"; + return ( + + + + {entry.definition.name} + {entry.blurb} + {!!entry.homepage && ( + entry.homepage && Linking.openURL(entry.homepage)} + style={styles.linkRow} + hitSlop={6} + > + + Learn more + + )} + + onInstall(entry)} + style={[styles.addBtn, oauth && styles.addBtnDisabled]} + hitSlop={6} + > + + + {oauth ? "OAuth" : "Add"} + + + + ); + }) + )} + + + {/* 4. Add an MCP server (OAuth supported via deep-link sign-in) */} + + MCP servers + {browsableMcp.length === 0 ? ( + + {notInstalledMcp.length === 0 + ? "No new MCP servers to add." + : "No MCP servers match your search."} + + ) : ( + <> + + Some providers only allow sign-in from a desktop browser and will reject the + mobile connection — connect those in the desktop app instead. + + {browsableMcp.map((entry) => { + const httpOk = entry.definition.transport === "http"; + const oauth = entry.definition.authMode === "oauth"; + return ( + + + + {entry.definition.name} + {entry.blurb} + {!httpOk && SSE transport not supported on mobile} + {!!entry.homepage && ( + entry.homepage && Linking.openURL(entry.homepage)} + style={styles.linkRow} + hitSlop={6} + > + + Learn more + + )} + + void onInstallMcp(entry)} + disabled={!httpOk || busyId === entry.id} + style={[styles.addBtn, !httpOk && styles.addBtnDisabled]} + hitSlop={6} + > + {busyId === entry.id ? ( + + ) : ( + <> + + + {oauth ? "Connect" : "Add"} + + + )} + + + ); + })} + + )} + + + + + + Services, servers, and toggles are stored on this device only and never sync. + + + + )} + + {/* Android API-key prompt (iOS uses the native Alert.prompt). */} + setKeyPrompt(null)} + > + + + + Add {keyPrompt?.entry.definition.name} + + + {keyPrompt?.entry.definition.apiKeyUrl + ? `Paste your API key. Get one at ${keyPrompt.entry.definition.apiKeyUrl}.` + : "Paste your API key for this service."} + + setKeyPrompt((cur) => (cur ? { ...cur, value: v } : cur))} + placeholder="API key" + placeholderTextColor={t.textTertiary} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + autoFocus + /> + + setKeyPrompt(null)} + style={styles.modalBtn} + accessibilityRole="button" + accessibilityLabel="Cancel" + > + Cancel + + { + if (!keyPrompt) return; + const { entry, value } = keyPrompt; + setKeyPrompt(null); + void installWithKey(entry, value); + }} + style={[styles.modalBtn, styles.modalBtnPrimary]} + accessibilityRole="button" + accessibilityLabel="Install service" + > + Install + + + + + + + ); +} + +function CategoryChip({ + label, + active, + onPress, + styles, +}: { + label: string; + active: boolean; + onPress: () => void; + styles: ReturnType; +}) { + return ( + + {label} + + ); +} + +function makeStyles(t: Theme) { + return StyleSheet.create({ + flex: { flex: 1, backgroundColor: t.surface }, + loadingBox: { paddingVertical: 48, alignItems: "center" }, + body: { flex: 1 }, + bodyContent: { padding: 18, gap: 20 }, + section: { gap: 8 }, + sectionHead: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, + sectionLabel: { ...typeScale.overline, color: t.textTertiary }, + sectionHint: { ...typeScale.caption, color: t.textTertiary, marginBottom: 2 }, + chipRow: { gap: 6, paddingVertical: 2, paddingRight: 4 }, + chip: { + borderWidth: 1, + borderColor: t.border, + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 5, + backgroundColor: t.surface2, + }, + chipActive: { + borderColor: t.accent, + backgroundColor: t.accentDim, + }, + chipText: { ...typeScale.caption, color: t.textSecondary }, + chipTextActive: { color: t.textPrimary }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + backgroundColor: t.surface2, + borderWidth: 1, + borderColor: t.border, + borderRadius: 12, + paddingVertical: 10, + paddingHorizontal: 12, + }, + rowMain: { flex: 1, gap: 2 }, + rowTitle: { ...typeScale.control, color: t.textPrimary }, + rowSub: { ...typeScale.caption, color: t.textSecondary }, + iconBtn: { padding: 4 }, + linkRow: { flexDirection: "row", alignItems: "center", gap: 4, marginTop: 2 }, + linkText: { ...typeScale.micro, color: t.textTertiary }, + addBtn: { + flexDirection: "row", + alignItems: "center", + gap: 4, + backgroundColor: t.accent, + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 8, + }, + addBtnDisabled: { backgroundColor: t.surface, borderWidth: 1, borderColor: t.border }, + addBtnText: { ...typeScale.label, color: t.accentFg }, + addBtnTextDisabled: { color: t.textTertiary }, + errorText: { ...typeScale.caption, color: t.danger }, + statusText: { ...typeScale.micro, color: t.textTertiary, marginTop: 2 }, + // MCP server card: header + collapsible per-tool manager. + mcpCard: { + backgroundColor: t.surface2, + borderWidth: 1, + borderColor: t.border, + borderRadius: 12, + paddingVertical: 10, + paddingHorizontal: 12, + gap: 8, + }, + mcpHeader: { flexDirection: "row", alignItems: "center", gap: 12 }, + mcpToolsBar: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + borderTopWidth: 1, + borderTopColor: t.border, + paddingTop: 8, + }, + mcpToolsToggle: { flexDirection: "row", alignItems: "center", gap: 6, flex: 1 }, + mcpToolsLabel: { ...typeScale.micro, color: t.textSecondary }, + mcpToolRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + backgroundColor: t.surface, + borderWidth: 1, + borderColor: t.border, + borderRadius: 10, + paddingVertical: 8, + paddingHorizontal: 10, + }, + mcpToolName: { ...typeScale.label, color: t.textPrimary }, + // Built-in disclosure + disclosureHead: { flexDirection: "row", alignItems: "center", gap: 6 }, + disclosureTitle: { ...typeScale.overline, color: t.textTertiary }, + disclosureCount: { + ...typeScale.micro, + color: t.textTertiary, + backgroundColor: t.surface2, + borderWidth: 1, + borderColor: t.border, + borderRadius: 999, + paddingHorizontal: 7, + paddingVertical: 1, + overflow: "hidden", + }, + builtinGroup: { gap: 6, marginTop: 4 }, + builtinGroupLabel: { ...typeScale.micro, color: t.textTertiary, marginTop: 4, marginBottom: 2 }, + builtinRow: { + backgroundColor: t.surface2, + borderWidth: 1, + borderColor: t.border, + borderRadius: 10, + paddingVertical: 8, + paddingHorizontal: 12, + gap: 1, + }, + builtinName: { ...typeScale.label, color: t.textPrimary }, + builtinDesc: { ...typeScale.caption, color: t.textSecondary }, + footer: { flexDirection: "row", alignItems: "center", gap: 6, paddingTop: 4 }, + footerText: { ...typeScale.micro, color: t.textTertiary, flex: 1 }, + modalBackdrop: { + flex: 1, + backgroundColor: t.scrim, + alignItems: "center", + justifyContent: "center", + padding: 24, + }, + modalCard: { + width: "100%", + maxWidth: 420, + backgroundColor: t.surface, + borderWidth: 1, + borderColor: t.border, + borderRadius: 14, + padding: 18, + gap: 10, + }, + modalTitle: { ...typeScale.subtitle, color: t.textPrimary }, + modalMessage: { ...typeScale.caption, color: t.textSecondary }, + modalInput: { + ...typeScale.body, + color: t.textPrimary, + backgroundColor: t.surface2, + borderWidth: 1, + borderColor: t.border, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + }, + modalActions: { flexDirection: "row", justifyContent: "flex-end", gap: 8, marginTop: 4 }, + modalBtn: { paddingVertical: 8, paddingHorizontal: 14, borderRadius: 8 }, + modalBtnText: { ...typeScale.label, color: t.textSecondary }, + modalBtnPrimary: { backgroundColor: t.accent }, + modalBtnPrimaryText: { ...typeScale.label, color: t.accentFg }, + }); +} diff --git a/mobile/changelogs/v0.1.3.md b/mobile/changelogs/v0.1.3.md index e8ce6a6a..64d9592a 100644 --- a/mobile/changelogs/v0.1.3.md +++ b/mobile/changelogs/v0.1.3.md @@ -4,7 +4,9 @@ Polish for the workspace switcher and Knowledge Graph controls. ### New -- **The assistant can search and read the web**: Chat can now go beyond your notes and tasks to answer questions about current events, external facts, and documentation — it searches the live web and can open a result to read the full page, citing its sources as links. Turn it on under **Settings → AI → Web search** by choosing a provider (**Tavily** or **Brave**) and adding that provider's API key. Tavily gives clean, LLM-ready results and can read full pages; Brave provides web search, and you can optionally add a Tavily key alongside it to let the assistant read pages in full. Keys are stored securely in the device keychain and are never synced or sent anywhere except your chosen search provider. Works with any chat backend (Apple Intelligence, Rork, or OpenAI-compatible). +- **The assistant can search and read the web**: Chat can now go beyond your notes and tasks to answer questions about current events, external facts, and documentation — it searches the live web and can open a result to read the full page, citing its sources as links. Add a web provider from the new **Settings → AI → Tools & Services** screen — connect **Tavily** (sign in, no key to copy) or add **Brave Search** with an API key. Credentials are stored securely in the device keychain and never sync. Works with any chat backend (Apple Intelligence, Rork, or OpenAI-compatible). +- **Add community services and see the assistant's tools**: A new **Settings → AI → Tools & Services** screen lets you browse Cairn's community connector catalog and install HTTP services (like **Brave Search**) with just an API key — no app update needed, and an installed service works in every project on your device. Each installed service has an on/off switch so you can silence one you don't want the assistant to reach for. The screen also lists Cairn's built-in tools (collapsed, grouped into Read and Write) so you can see exactly what the assistant can do with your notes and tasks — these are always on and match the desktop app. Services and toggles live only on your device and never sync; API keys are stored in the device keychain. +- **Connect MCP servers to the assistant**: Tools & Services can now also install **MCP servers** from the community catalog — including ones that need you to sign in (OAuth). Tap **Connect**, approve access in the browser, and the server's tools become available to the assistant in chat (across every project on your device). Each connected server shows its tools in an expandable list, each with its own on/off switch, plus a refresh to re-discover tools; sign-in tokens are stored securely in the device keychain and never sync, and you can sign out or remove a server anytime. Connectors show their brand logo. (Streamable-HTTP MCP servers are supported. A few providers only allow sign-in from a pre-registered desktop app and can't be connected from mobile yet — the screen notes this.) - **Export a note as a PDF**: The note menu now has an **Export as PDF** action alongside Export as Markdown. It renders the note into a beautifully formatted PDF — the same layout, typography, code blocks, tables, and callouts as the desktop PDF export (following your light/dark appearance) — then opens the system share sheet so you can Print it, save it to Files, or send it via Messages/Mail/AirDrop. Callouts (`> [!note]`, `[!warning]`, etc.) and `[[wikilinks]]` render with the same styling as desktop. - **Long-press a note to move, pin, or delete it**: Press and hold any note in a project's Notes list to open a quick actions menu — **Pin/Unpin**, **Move to project**, **Move to folder**, or **Delete** — mirroring the note menu on desktop. "Move to project" and "Move to folder" open a picker of your other projects / this project's folders. This is the first way to move a note between projects on mobile (there was previously no manual way to do it at all). - **Project Overview on mobile**: Opening a project now starts on a new **Overview** tab (alongside Notes and Board) that gives you an at-a-glance snapshot — a completion ring, note / open-task / overdue counts, a breakdown of tasks by column and by priority, what's due soon, your pinned and recent notes, and a recent-activity feed. Tapping anything jumps straight to it. This mirrors the desktop project Overview. @@ -14,6 +16,9 @@ Polish for the workspace switcher and Knowledge Graph controls. ### Improvements +- **Search & filter the community catalog**: The Tools & Services screen now has a search box and category chips (Dev & Code, Finance, Search & Web, and so on) that filter both the installable services and MCP servers at once — matching the desktop Browse Community experience. As the catalog grows, finding a specific connector is much quicker. +- **Services can bundle several tools**: An installed service from the community catalog can now offer multiple related operations — each its own tool — sharing one connection and API key, shown as an expandable list with a per-tool switch (like MCP servers). For example, Hacker News now offers both "search" and "search by date". Existing single-tool services are unchanged. +- **Chat shows tools while they're running**: When the assistant uses a tool — especially a slower one like a web search or an MCP call — its chip now appears immediately with a spinner and updates when the result arrives, instead of only showing up once the tool had already finished. So you can see what the assistant is doing in real time. - **Tidier Knowledge Graph header**: The label-density control now lives in the top-left of the header (a native menu) instead of taking up space in the in-graph toolbar, and the Semantic-links toggle now sits next to the Hulls toggle — so the graph controls fit better and read more clearly. The Semantic toggle also shows a spinner while it computes connections (which can take a moment on device). - **Snappier graph toggles**: Toggling semantic links, cluster hulls, or label density now updates the graph in place instead of rebuilding the whole visualisation each time — no more full redraw / layout restart, so the controls respond immediately. - **Calendar day selection matches desktop**: The selected (and today's) day are now shown with a soft accent background wash instead of an outline, so the grid reads more cleanly. @@ -21,6 +26,8 @@ Polish for the workspace switcher and Knowledge Graph controls. ### Fixes +- **Clearer message when an MCP server needs desktop sign-in**: Some providers (e.g. Asana) only accept an OAuth sign-in from a desktop app and reject the mobile connection. Instead of a cryptic technical error, Cairn now explains it plainly — "{Provider} doesn't allow sign-in from a phone … connect it in the Cairn desktop app instead" — under a "Connect on desktop" heading. + - **Knowledge Graph no longer goes blank after the app was backgrounded**: On iOS the system can terminate a web view's content process while the app is in the background (to reclaim memory), which left the Knowledge Graph (and, more rarely, rendered math/diagram blocks in notes) blank until you fully restarted the app — in-app navigation didn't recover it. Cairn now detects the terminated content process and reloads the view automatically, so the graph and note diagrams come back on their own. - **Chat opens at the latest message and stays the right size**: The Chat transcript now jumps to the newest message when you open the tab (and when the app resumes), instead of sometimes opening scrolled to the top and only snapping down after you sent a message. The message list also gets a stable height so it tracks content correctly rather than mis-sizing under the keyboard/composer. - **No more ghost bands after reopening Chat**: Returning to the Chat screen after the app had been backgrounded could briefly show faint vertical bands over the messages (an iOS snapshot artifact from the frosted composer). Chat now repaints on resume so they no longer appear. @@ -43,7 +50,8 @@ Polish for the workspace switcher and Knowledge Graph controls. - **Folders that differ only in capitalisation are now merged**: Notes filed under `Mobile` and `mobile` (or `Mobile/AI` and `mobile/ai`) were listed as two separate folders. They now collapse into one — matched case-insensitively, keeping the first casing seen as the folder's name. The assistant's folder list dedupes the same way. - **Reschedule uses timezone-safe dates**: The project Overview's "due soon" list now compares due dates by local calendar day (matching the rest of the app), so a task due today is never shown a day early/late in some timezones. - **Sync screen tidied up**: Removed the duplicate workspace picker from the Sync screen — switch workspaces from the Cairn icon in the Projects header. The Sync screen now focuses purely on sync status, iCloud availability, and conflicts. -- **The assistant no longer gets stuck on a failed note edit**: When the assistant tried to patch a note but the exact text wasn't found (or the note id was stale), the edit simply reported failure with no reason — so the assistant would blindly repeat the same failing call. It now returns actionable guidance instead: re-read the note and copy its exact current text before retrying, search for the correct note id when it's stale, or use "append to note" to add new content — and is told explicitly not to retry the same call. +- **The assistant no longer silently loses a note's content on a malformed edit**: On the on-device (Apple) model, if the assistant emitted slightly-malformed JSON for a note write (for example markdown with real line breaks in the middle of the text), the app previously ran the tool with *empty* arguments — silently creating or overwriting a note with no content. The assistant now parses those arguments the same forgiving-but-lossless way the desktop app does (fixing only unambiguous JSON formatting slips, never changing your actual text), and if it still can't parse them it reports the error back to the model to retry instead of writing an empty note. +- **Tap web results straight from chat**: When the assistant runs a web search (or another external tool that returns a link), its tool chip is now tappable and opens that page in your browser — the same way note/task chips open in-app. - **See exactly what differs in a sync conflict**: The Sync Conflicts screen used to show the original note and the conflicted copy as two plain text previews, leaving you to spot the difference by eye. It now shows a unified diff — lines only in the copy are highlighted green (+), lines only in the original red (−), unchanged lines dimmed — with a count of what changed, and says so plainly when the two bodies are actually identical. - **Open notes and tasks straight from chat**: Chat is now far more reliable at jumping to what it mentions. When the assistant creates or touches a note or task, its little tool chip is now tappable and takes you straight there. `[[Wikilinks]]` in replies now open **tasks** too (not just notes), and both are matched to the exact item at the moment they're shown — so taps no longer occasionally open the wrong item or quietly do nothing. @@ -58,8 +66,13 @@ Polish for the workspace switcher and Knowledge Graph controls. - Chat controls now expose proper labels to VoiceOver: the send button (with its disabled state), the attachment "remove" button, and tappable tool chips that open a note or task are all announced as buttons with descriptive labels. +- **Faster Tools & Services screen**: When a community catalog is already cached, the screen now shows it instantly and refreshes in the background, instead of blocking on a full-screen spinner. The catalog fetch also has a 20-second timeout so a hung network falls back to the cache rather than spinning forever. +- **Android API-key entry**: Installing a key-based service on Android now opens an in-app key prompt (Android has no native text-input alert), so key-based connectors can be added there too — iOS keeps its native prompt. + ### Under the hood +- Multi-operation HTTP services: the shared service core + registry schema gained `baseUrl` + `operations[]` (path templating with `{placeholders}`, per-argument path/query/body placement, and connector-supplied static query params), so one connector can expose several tools over a real multi-endpoint API. Mobile builds one namespaced tool per operation and routes calls through the shared `resolveOperation` + `buildOperationRequest`, identical to desktop. Backward-compatible with existing single-endpoint services. +- 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); a failed MCP `connect` now closes the half-open client/transport instead of leaking it; malformed tool definitions on both desktop and mobile now return a clean error instead of throwing into the chat loop; and the service call error message reports the fully-resolved request URL. Added tests for the path-origin rule. Also gave the icon-only controls on the Tools & Services screen accessibility labels. - Renamed the assistant's semantic-search tools to match the desktop app (`search_notes_semantic` / `search_tasks_semantic`), so both surfaces expose one consistent tool vocabulary. No user-visible change. - Consolidated repeated UI patterns into shared building blocks (a uniform section-label style, priority/column chips, result rows, the not-found placeholder, the Markdown note editor, and refresh-on-focus / workspace-discovery hooks) so screens stay consistent and easier to maintain. No visible change. - Moved the note section-splitter and vector-similarity math into shared code used by both the desktop and mobile apps, so semantic-search indexing and ranking stay identical across devices. No visible change. @@ -80,3 +93,6 @@ Polish for the workspace switcher and Knowledge Graph controls. - Moved the Knowledge Graph's WebView document builder (the bundled D3 library plus the force-simulation and radial-sunburst rendering code) out of the 1,000-line graph component into its own file. Purely internal; the graph renders and behaves exactly the same. - The "ready to work on" task list now resolves each task's blockers in a single batched database query instead of one lookup per blocker. Same result; fewer queries. - The unscheduled-tray drop-zone registration and the sync-conflict diff view were made more render-stable (a memoised diff and a stable zone-ref callback), avoiding needless re-registration / recomputation. No visible change. +- Moved the tolerant tool-argument parser and the external-link extractor into shared code used by both desktop and mobile, so the assistant handles model JSON identically everywhere. The pure HTTP-service request-shaping (query/body building, argument coercion, response trimming) and the community-registry manifest validation were also moved to shared, laying the groundwork for mobile to consume the same connector catalog as desktop. Purely internal; no visible change. +- The mobile app now consumes the community connector registry directly: it fetches and caches the same `manifest.json` the desktop uses (validated by the same shared parser), stores installed services/servers and per-tool on/off toggles in a device-global local store, and resolves each connector's secret from the device keychain. Installed HTTP services run through the shared request-shaping core (the same one the desktop uses), so a connector behaves identically on both platforms. The built-in Tavily/Brave web-search code was removed entirely — web search is now just another registry connector (Tavily via MCP, Brave via an HTTP service), so there's a single definition of each instead of hardcoded copies. +- Mobile MCP support reuses the stock `@modelcontextprotocol/sdk` on-device: the streamable-HTTP client transport runs on Hermes with Expo's `fetch` injected (its response body is a real streaming `ReadableStream`, so the SDK's SSE/stream pipeline works unchanged). OAuth uses the same SDK `auth()` orchestrator as desktop, but — since a phone can't run desktop's loopback redirect listener — sign-in goes through a small hosted https redirect page that forwards to the app's `cairn://` deep link (via an in-app browser session), with a `crypto.subtle`/`getRandomValues` shim (backed by `expo-crypto`) so the SDK's PKCE works on React Native. Client registration uses Client ID Metadata Documents (CIMD / SEP-991) — a static JSON file describing Cairn as a public PKCE client — falling back to Dynamic Client Registration where CIMD isn't supported. Tool namespacing, tool→OpenAI conversion, result stringification, and the OAuth-callback parser were moved into shared code so desktop and mobile behave identically; discovered MCP tools are cached in the device-global store so the model's tool list stays synchronous while calls reconnect on demand. diff --git a/mobile/eslint.config.js b/mobile/eslint.config.js index 32c9f869..063fc59b 100644 --- a/mobile/eslint.config.js +++ b/mobile/eslint.config.js @@ -28,6 +28,14 @@ module.exports = [ // Reanimated API works (it's not a React prop/hook value). Off for RN. "react-hooks/immutability": "off", + // The MCP SDK ships subpath entry points via package.json "exports" with + // .js specifiers (e.g. @modelcontextprotocol/sdk/client/streamableHttp.js). + // Metro (unstable_enablePackageExports) and tsc both resolve these, but + // eslint-plugin-import's resolver doesn't read the exports map, so it false- + // positives on no-unresolved. Ignore that one package rather than weaken the + // rule globally. + "import/no-unresolved": ["error", { ignore: ["^@modelcontextprotocol/sdk/"] }], + // Fires on well-established patterns like seeding a controlled input when // a modal opens or restoring state on mount. These are correct uses of // useEffect — downgrade to warn (matches the repo-root web config). diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 1363cbfe..04fc5946 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -11,12 +11,15 @@ "dependencies": { "@expo/material-symbols": "^0.1.1", "@expo/ui": "~57.0.6", + "@modelcontextprotocol/sdk": "^1.29.0", "@react-native-community/datetimepicker": "9.1.0", "ai": "^7.0.29", "expo": "~57.0.6", + "expo-auth-session": "~57.0.5", "expo-build-properties": "~57.0.5", "expo-clipboard": "~57.0.1", "expo-constants": "~57.0.2", + "expo-crypto": "~57.0.1", "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.0", "expo-glass-effect": "~57.0.1", @@ -31,6 +34,7 @@ "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "expo-updates": "~57.0.7", + "expo-web-browser": "~57.0.2", "js-tiktoken": "^1.0.21", "katex": "^0.17.0", "lowlight": "^3.3.0", @@ -2285,6 +2289,18 @@ "excpretty": "build/cli.js" } }, + "node_modules/@hono/node-server": { + "version": "1.19.15", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", + "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2470,6 +2486,68 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -4483,6 +4561,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -4981,6 +5098,71 @@ "node": ">=0.6" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -5111,7 +5293,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5125,7 +5306,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5436,12 +5616,52 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -5455,6 +5675,23 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -6380,7 +6617,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -6525,7 +6761,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6572,7 +6807,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7158,6 +7392,18 @@ "node": ">=6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", @@ -7244,6 +7490,46 @@ "react-native": "*" } }, + "node_modules/expo-auth-session": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-57.0.5.tgz", + "integrity": "sha512-4u/CIMCrQ88QO9AKSaw61Vva+Gmtil254xBjDB2SuAeyN/I9mNxZKkG0xtcm3iDSmJFMuHkYDKNo3RvTn3sz+Q==", + "license": "MIT", + "dependencies": { + "expo-application": "~57.0.2", + "expo-constants": "~57.0.7", + "expo-crypto": "~57.0.1", + "expo-linking": "~57.0.4", + "expo-web-browser": "~57.0.2", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-auth-session/node_modules/expo-application": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.2.tgz", + "integrity": "sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-auth-session/node_modules/expo-constants": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.7.tgz", + "integrity": "sha512-ShDwaKnh3UieCQ/dG0kO8PuicTTatn3WDGmXbq/fukyzPXWhdUxf37DINVDQS6D3DDG7nfqItij+QvnDHSQhTg==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.4.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-build-properties": { "version": "57.0.5", "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.5.tgz", @@ -7282,6 +7568,15 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.1.tgz", + "integrity": "sha512-xwegXQw3ATgeL1ZuqbSNrGzOeG+zNeh6Z6DSJk825Qpa3TEQQ1kG3ioE1p3g/SNF373BAVz2iBKUTSytlIbBRA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-document-picker": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.1.tgz", @@ -7379,12 +7674,12 @@ } }, "node_modules/expo-linking": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.3.tgz", - "integrity": "sha512-uF5PFfIQ0cjEGijzFuN8gU5eLqWs6BgS29Zp4ddP79XPMobZmS6qzdGyCOZTnZz3UWR17rFAJst1UntuZg9/6Q==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.4.tgz", + "integrity": "sha512-e1alfHNJdywIfJkCuKMc6M3hBfAGPd2gKMeF/6V7qwFWzHCS2mTBqU+KaO4FLpltA5Nt6CYEx6zmUlGfUF+8lA==", "license": "MIT", "dependencies": { - "expo-constants": "~57.0.5", + "expo-constants": "~57.0.7", "invariant": "^2.2.4" }, "peerDependencies": { @@ -7392,6 +7687,19 @@ "react-native": "*" } }, + "node_modules/expo-linking/node_modules/expo-constants": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.7.tgz", + "integrity": "sha512-ShDwaKnh3UieCQ/dG0kO8PuicTTatn3WDGmXbq/fukyzPXWhdUxf37DINVDQS6D3DDG7nfqItij+QvnDHSQhTg==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.4.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-manifests": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.1.tgz", @@ -7767,6 +8075,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/expo-web-browser": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.2.tgz", + "integrity": "sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "57.0.8", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.8.tgz", @@ -8073,6 +8391,173 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8093,6 +8578,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-dotslash": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", @@ -8280,6 +8781,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -8371,7 +8881,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8405,7 +8914,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8548,7 +9056,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8624,7 +9131,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8706,6 +9212,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -8897,6 +9412,24 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -9198,6 +9731,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9492,6 +10031,15 @@ "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -9567,6 +10115,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10227,7 +10781,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10245,12 +10798,37 @@ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -10907,7 +11485,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11037,6 +11614,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", @@ -11357,6 +11943,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11375,6 +11971,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-up": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", @@ -11645,6 +12250,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11655,6 +12273,22 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -11691,6 +12325,37 @@ "node": ">= 0.6" } }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -12202,6 +12867,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", @@ -12286,6 +12960,22 @@ "points-on-path": "^0.2.1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -12615,7 +13305,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -12635,7 +13324,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -12652,7 +13340,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -12671,7 +13358,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -13241,6 +13927,37 @@ "node": ">=8" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -13769,6 +14486,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "7.5.12", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.12.tgz", @@ -13923,6 +14646,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/mobile/package.json b/mobile/package.json index faf8683a..c8530f76 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -19,12 +19,15 @@ "dependencies": { "@expo/material-symbols": "^0.1.1", "@expo/ui": "~57.0.6", + "@modelcontextprotocol/sdk": "^1.29.0", "@react-native-community/datetimepicker": "9.1.0", "ai": "^7.0.29", "expo": "~57.0.6", + "expo-auth-session": "~57.0.5", "expo-build-properties": "~57.0.5", "expo-clipboard": "~57.0.1", "expo-constants": "~57.0.2", + "expo-crypto": "~57.0.1", "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.0", "expo-glass-effect": "~57.0.1", @@ -39,6 +42,7 @@ "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", "expo-updates": "~57.0.7", + "expo-web-browser": "~57.0.2", "js-tiktoken": "^1.0.21", "katex": "^0.17.0", "lowlight": "^3.3.0", diff --git a/mobile/src/chat/agent.ts b/mobile/src/chat/agent.ts index 0bf04038..cce80c86 100644 --- a/mobile/src/chat/agent.ts +++ b/mobile/src/chat/agent.ts @@ -18,7 +18,7 @@ import { type FilePart, type ChatUsage, } from "./providers/types"; -import { toolsForAgent, TOOL_MAP } from "./tools"; +import { toolsForAgent, allToolMap } from "./tools"; // Max model round-trips per user turn. Each turn is one model call; a turn that // requests tools runs them all, then loops for the model's follow-up (which sees @@ -28,9 +28,10 @@ import { toolsForAgent, TOOL_MAP } from "./tools"; const MAX_TURNS = 30; export interface AgentEvent { - type: "text-delta" | "reasoning-delta" | "tool" | "final" | "error"; + type: "text-delta" | "reasoning-delta" | "tool-start" | "tool" | "final" | "error"; delta?: string; // for text-delta / reasoning-delta tool?: string; + toolCallId?: string; // correlates a "tool-start" with its later "tool" args?: unknown; result?: unknown; text?: string; // full text for final / error @@ -64,7 +65,7 @@ function systemMessage(): UIMessage { "You are Cairn's mobile assistant for the user's notes and tasks; writes sync to their desktop.", `The current date is ${humanDate} (${isoDate}). Resolve relative dates like "tomorrow"/"next week" against it, and pass dates to tools as YYYY-MM-DD.`, "Call get_cairn_context first to get project ids, columns, and tags (there is no separate 'list projects' tool), then reuse them — never invent an id. Choose the tool whose description matches the request.", - "For information not in the user's notes/tasks — current events, external facts, docs — use web_search, then web_extract to read a result in full. Cite sources as markdown links.", + "For information beyond the user's notes/tasks — current events, external facts, docs — use any connected web/search tools available to you, and cite sources as markdown links.", "When you mention a specific note or task, link it as [[id]] using its exact id (it renders as the title and can't be confused with a same-titled item); if you don't have the id, [[Title]] also works. After a write, briefly confirm. Answer in concise markdown.", ].join(" "), }, @@ -115,6 +116,10 @@ export async function runAgent( signal?: AbortSignal, ): Promise { const tools = toolsForAgent(); + // Resolve the executable map ONCE for this run so the tools we advertise and + // the tools we can execute stay in lock-step even if a toggle/install changes + // mid-run. Includes built-ins + enabled installed services. + const toolMap = allToolMap(); let finalText = ""; // Accumulated reasoning ("thinking") text across the run's turns (PCC only). let reasoning = ""; @@ -217,7 +222,12 @@ export async function runAgent( }; assistant.parts.push(toolPart as UIPart); - const tool = TOOL_MAP.get(call.name); + // Announce the call BEFORE running it, so the UI can show a "running" chip + // for slow tools (MCP/web search can take seconds) instead of nothing until + // the result lands. + onEvent?.({ type: "tool-start", tool: call.name, toolCallId: call.id, args: call.input }); + + const tool = toolMap.get(call.name); let result: unknown; if (!tool) { result = { error: `Unknown tool: ${call.name}` }; @@ -228,7 +238,7 @@ export async function runAgent( result = { error: err instanceof Error ? err.message : String(err) }; } } - onEvent?.({ type: "tool", tool: call.name, args: call.input, result }); + onEvent?.({ type: "tool", tool: call.name, toolCallId: call.id, args: call.input, result }); toolPart.state = "output-available"; toolPart.output = { type: "text", value: JSON.stringify(result) }; diff --git a/mobile/src/chat/crypto-polyfill.ts b/mobile/src/chat/crypto-polyfill.ts new file mode 100644 index 00000000..765f55d7 --- /dev/null +++ b/mobile/src/chat/crypto-polyfill.ts @@ -0,0 +1,74 @@ +/** + * Minimal Web Crypto shim for the MCP OAuth flow. + * + * The MCP SDK's OAuth path uses `pkce-challenge`, which reads + * `globalThis.crypto.getRandomValues` + `globalThis.crypto.subtle.digest( + * "SHA-256", …)` to build the PKCE verifier/challenge. React Native / Hermes + * ships neither `globalThis.crypto` nor a WebCrypto `subtle`, so without this the + * OAuth flow throws at `startAuthorization` (bundles fine, fails at runtime). + * + * We back the two operations pkce-challenge actually needs with `expo-crypto` + * (native, cryptographically secure). This is NOT a full WebCrypto polyfill — + * only `getRandomValues` and `subtle.digest` are provided, which is all the PKCE + * path uses. Installed idempotently; safe to import more than once. + * + * Import this once, as early as possible (top of app/_layout.tsx), before any + * OAuth code runs. + */ + +import * as ExpoCrypto from "expo-crypto"; + +interface MinimalSubtle { + digest(algorithm: AlgorithmIdentifier, data: BufferSource): Promise; +} + +function installCryptoPolyfill(): void { + // Use a loose shape here: the ambient DOM `Crypto`/`SubtleCrypto` types demand + // a full WebCrypto surface we deliberately don't implement (only the two PKCE + // ops). Cast through unknown so we can install a partial, framework-free shim. + const g = globalThis as unknown as { + crypto?: { + getRandomValues?: (array: ArrayBufferView | null) => ArrayBufferView | null; + subtle?: MinimalSubtle; + randomUUID?: () => string; + }; + }; + + if (!g.crypto) { + Object.defineProperty(g, "crypto", { value: {}, configurable: true, writable: true }); + } + const crypto = g.crypto!; + + if (typeof crypto.getRandomValues !== "function") { + crypto.getRandomValues = (array: ArrayBufferView | null): ArrayBufferView | null => { + if (array == null) return array; + ExpoCrypto.getRandomValues(array as unknown as Parameters[0]); + return array; + }; + } + + if (typeof crypto.randomUUID !== "function") { + crypto.randomUUID = () => ExpoCrypto.randomUUID(); + } + + if (!crypto.subtle || typeof crypto.subtle.digest !== "function") { + const subtle: MinimalSubtle = { + async digest(algorithm: AlgorithmIdentifier, data: BufferSource): Promise { + const name = typeof algorithm === "string" ? algorithm : algorithm.name; + if (name !== "SHA-256") { + throw new Error(`crypto.subtle.digest polyfill only supports SHA-256 (got ${name}).`); + } + const bytes = + data instanceof Uint8Array + ? data + : ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data as ArrayBuffer); + return ExpoCrypto.digest(ExpoCrypto.CryptoDigestAlgorithm.SHA256, bytes); + }, + }; + crypto.subtle = subtle; + } +} + +installCryptoPolyfill(); diff --git a/mobile/src/chat/mcp-client.ts b/mobile/src/chat/mcp-client.ts new file mode 100644 index 00000000..0193a639 --- /dev/null +++ b/mobile/src/chat/mcp-client.ts @@ -0,0 +1,229 @@ +/** + * Mobile outbound MCP client — connects Cairn to remote streamable-HTTP MCP + * servers and exposes their tools to the chat/agent loop. The mobile counterpart + * to electron/lib/mcp-client.ts. + * + * Confirmed on-device (Track 3 spike): the stock @modelcontextprotocol/sdk + * `Client` + `StreamableHTTPClientTransport` run on Hermes when `expo/fetch` is + * injected as the transport's `fetch` (its Response.body is a real ReadableStream + * the SDK's TextDecoderStream pipeline consumes). Pure namespacing + tool-def + * conversion + result stringify are shared with desktop via @cairn/shared. + * + * Only streamable-HTTP is supported on mobile for now (the registry is HTTP-first + * anyway). OAuth servers connect via the SecureStoreOAuthProvider, which injects + * (and refreshes) the bearer from the keychain. Errors are returned as values, + * never thrown into the agent loop — a flaky server must not crash a run. + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { fetch as expoFetch } from "expo/fetch"; +import { + mcpToolsToOpenAI, + parseToolName, + stringifyToolResult, + type OpenAIToolDef, + type McpToolDef, +} from "@cairn/shared/chat/mcp-namespace"; +import { SecureStoreOAuthProvider } from "./mcp-oauth"; + +const CONNECT_TIMEOUT_MS = 20_000; +const CALL_TIMEOUT_MS = 60_000; +const IDLE_TIMEOUT_MS = 5 * 60_000; + +/** Runtime config for a mobile MCP server (subset of the stored config). */ +export interface McpServerRuntimeConfig { + id: string; + baseUrl: string; + /** Only "http" (streamable-HTTP) on mobile. */ + transport: "http" | "sse"; + headers?: Record; + authMode?: "none" | "oauth"; + oauthScope?: string; + name?: string; +} + +interface Conn { + client: Client; + transport: StreamableHTTPClientTransport; + idleTimer: ReturnType | null; + signature: string; +} + +const conns = new Map(); + +/** Stable signature of the connection-relevant fields; a change forces a rebuild. */ +function configSignature(cfg: McpServerRuntimeConfig): string { + return JSON.stringify({ + baseUrl: cfg.baseUrl, + transport: cfg.transport, + headers: cfg.headers ?? {}, + authMode: cfg.authMode ?? "none", + oauthScope: cfg.oauthScope ?? "", + }); +} + +function makeTransport(cfg: McpServerRuntimeConfig): StreamableHTTPClientTransport { + const url = new URL(cfg.baseUrl); + const fetchFn = expoFetch as unknown as typeof fetch; + if (cfg.authMode === "oauth") { + // The provider injects + refreshes the bearer from the keychain; static + // headers aren't used for OAuth servers. + const provider = new SecureStoreOAuthProvider(cfg.id, cfg.name ?? cfg.id, cfg.oauthScope); + return new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: fetchFn }); + } + const headers = cfg.headers ?? {}; + const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined; + return new StreamableHTTPClientTransport(url, { requestInit, fetch: fetchFn }); +} + +function touch(conn: Conn, serverId: string): void { + if (conn.idleTimer) clearTimeout(conn.idleTimer); + conn.idleTimer = setTimeout(() => void dispose(serverId), IDLE_TIMEOUT_MS); +} + +async function withTimeout(p: Promise, ms: number, label: string): Promise { + let timer: ReturnType; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + try { + return await Promise.race([p, timeout]); + } finally { + clearTimeout(timer!); + } +} + +/** Connect (or reuse a cached connection) to a server. Throws on failure. */ +async function connect(cfg: McpServerRuntimeConfig): Promise { + const signature = configSignature(cfg); + const existing = conns.get(cfg.id); + if (existing && existing.signature === signature) { + touch(existing, cfg.id); + return existing; + } + if (existing) await dispose(cfg.id); + + const client = new Client({ name: "cairn-mobile", version: "1.0.0" }, { capabilities: {} }); + const transport = makeTransport(cfg); + try { + await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `MCP connect to ${cfg.id}`); + } catch (e) { + // Connect (or its timeout) failed — close the half-open client/transport so + // the socket + refresh timers don't leak, then rethrow the original error. + await client.close().catch(() => {}); + await transport.close().catch(() => {}); + throw e; + } + const conn: Conn = { client, transport, idleTimer: null, signature }; + conns.set(cfg.id, conn); + touch(conn, cfg.id); + return conn; +} + +/** Disconnect and forget a server connection. Safe when not connected. */ +export async function dispose(serverId: string): Promise { + const conn = conns.get(serverId); + if (!conn) return; + conns.delete(serverId); + if (conn.idleTimer) clearTimeout(conn.idleTimer); + try { + await conn.client.close(); + } catch { + // Already gone. + } +} + +/** Disconnect every server (workspace switch / teardown). */ +export async function disposeAll(): Promise { + await Promise.all([...conns.keys()].map((id) => dispose(id))); +} + +/** + * List a server's tools as namespaced OpenAI function defs. Returns [] on any + * failure (logged) so a flaky server can't break the loop. + */ +export async function listTools(cfg: McpServerRuntimeConfig): Promise { + try { + const conn = await connect(cfg); + const res = await withTimeout(conn.client.listTools(), CALL_TIMEOUT_MS, `MCP listTools ${cfg.id}`); + return mcpToolsToOpenAI(cfg.id, (res.tools ?? []) as McpToolDef[]); + } catch (e) { + console.warn(`[mcp-client] listTools failed for ${cfg.id}:`, e instanceof Error ? e.message : e); + return []; + } +} + +/** Server tool with raw name + description, for the Settings tool list. */ +export interface McpToolInfo { + name: string; + description?: string; +} + +export interface ListMcpToolsResult { + ok: boolean; + tools: McpToolInfo[]; + error?: string; +} + +/** + * Connect + list a server's tools with raw names + descriptions (Settings). Keeps + * the connection cached (the user is configuring it). Never throws. + */ +export async function listToolsDetailed(cfg: McpServerRuntimeConfig): Promise { + try { + const conn = await connect(cfg); + const res = await withTimeout(conn.client.listTools(), CALL_TIMEOUT_MS, `MCP listToolsDetailed ${cfg.id}`); + const tools = (res.tools ?? []) as McpToolDef[]; + return { ok: true, tools: tools.map((t) => ({ name: t.name, description: t.description })) }; + } catch (e) { + return { ok: false, tools: [], error: e instanceof Error ? e.message : String(e) }; + } +} + +/** + * Call a namespaced MCP tool (`mcp____`). Returns the textual + * result, or an error string (never throws). `cfg` must be the matching server. + */ +export async function callTool( + cfg: McpServerRuntimeConfig, + namespaced: string, + args: Record, +): Promise { + const parsed = parseToolName(namespaced); + if (!parsed || parsed.serverId !== cfg.id) { + return `Error: "${namespaced}" is not a tool of MCP server ${cfg.id}`; + } + try { + const conn = await connect(cfg); + const res = await withTimeout( + conn.client.callTool({ name: parsed.toolName, arguments: args }), + CALL_TIMEOUT_MS, + `MCP callTool ${namespaced}`, + ); + return stringifyToolResult(res); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.warn(`[mcp-client] callTool ${namespaced} failed:`, msg); + return `Error calling ${namespaced}: ${msg}`; + } +} + +/** + * Test a connection for the Settings "test" action: connect + list tools, return + * a small status payload, always dispose afterward. Never throws. + */ +export async function testConnection( + cfg: McpServerRuntimeConfig, +): Promise<{ ok: boolean; toolCount?: number; toolNames?: string[]; error?: string }> { + try { + const conn = await connect(cfg); + const res = await withTimeout(conn.client.listTools(), CONNECT_TIMEOUT_MS, `MCP test ${cfg.id}`); + const tools = (res.tools ?? []) as McpToolDef[]; + return { ok: true, toolCount: tools.length, toolNames: tools.map((t) => t.name) }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } finally { + await dispose(cfg.id); + } +} diff --git a/mobile/src/chat/mcp-oauth.ts b/mobile/src/chat/mcp-oauth.ts new file mode 100644 index 00000000..1931a471 --- /dev/null +++ b/mobile/src/chat/mcp-oauth.ts @@ -0,0 +1,337 @@ +/** + * MCP OAuth 2.1 for mobile — the device-side counterpart to desktop's + * electron/lib/mcp-oauth.ts. + * + * Same machinery (the MCP SDK drives discovery, dynamic client registration, + * PKCE, token exchange + refresh via its transport-agnostic `auth()`), but the + * two platform-specific pieces differ: + * + * - STORAGE: OAuth artefacts (client registration, PKCE verifier, tokens) live + * in expo-secure-store (iOS keychain / Android keystore), namespaced by + * server id — never in SQLite, never synced. Mirrors desktop's + * KeychainOAuthProvider, but the getters are async (secure-store is async), + * which the SDK's OAuthClientProvider interface allows. + * - REDIRECT: desktop uses an RFC 8252 loopback listener; mobile CANNOT bind a + * local HTTP server, so it uses the `cairn://oauth/callback` deep link + * exclusively, opened via expo-web-browser `openAuthSessionAsync`, which + * returns the captured callback URL directly (no separate listener/registry + * needed). The cairn:// scheme is already declared in mobile/app.json. + * + * PKCE hashing uses globalThis.crypto (shimmed by crypto-polyfill.ts). + */ + +import * as SecureStore from "expo-secure-store"; +import * as WebBrowser from "expo-web-browser"; +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientMetadata, + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { OAUTH_REDIRECT_URI, parseOAuthCallback } from "@cairn/shared/chat/oauth-callback"; + +/** + * The https redirect we ADVERTISE to authorization servers. Strict providers + * (Canva) reject a custom-scheme (cairn://) redirect_uri at /authorize, so we + * register an https bounce page instead. That page (static, on gerardbuilds.com) + * forwards the ?code&state straight to OAUTH_REDIRECT_URI (cairn://oauth/callback), + * which the in-app auth session intercepts. This URL is also listed in the CIMD + * doc's redirect_uris. See appsbygerard/cairn/oauth/index.html. + */ +const HTTPS_REDIRECT_URI = "https://gerardbuilds.com/cairn/oauth"; + +// Per-server secure-store keys (mirror desktop's KEY_* artefacts). +const KEY_CLIENT_INFO = "oauth_client"; +const KEY_TOKENS = "oauth_tokens"; +const KEY_VERIFIER = "oauth_verifier"; + +/** Refresh a little before the real expiry to avoid racing a 401 mid-request. */ +const TOKEN_REFRESH_SKEW_MS = 60_000; + +/** + * CIMD (Client ID Metadata Document, SEP-991) — a static JSON file hosted on our + * own https domain that describes Cairn as an OAuth client. Its URL IS the + * client_id. When an MCP server advertises `client_id_metadata_document_supported` + * the SDK uses this URL and SKIPS Dynamic Client Registration entirely. + * + * This is what lets CIMD-capable providers (e.g. Tavily) authenticate without + * DCR. The redirect is validated against the `redirect_uris` list INSIDE this + * trusted https document. NOTE: some providers advertise CIMD but their endpoint + * is broken (Canva's CIMD /authorize 500s), so startAuth falls back to DCR per + * server. Both modes use the https bounce redirect (see HTTPS_REDIRECT_URI), + * which forwards to cairn://oauth/callback. + * + * The file lists the https bounce URL + cairn://oauth/callback in redirect_uris + * and sets token_endpoint_auth_method:"none" (Cairn is a public/PKCE client). + * https://gerardbuilds.com/cairn/oauth-client.json (repo: appsbygerard/cairn/) + */ +const CLIENT_METADATA_URL = "https://gerardbuilds.com/cairn/oauth-client.json"; + +/** OAuth config for an MCP server (subset of the stored server config). */ +export interface OAuthServerConfig { + id: string; + /** The MCP server URL the SDK runs discovery against. */ + serverUrl: string; + scope?: string; +} + +/** Tokens carry an absolute expiry we stamp on save (SDK ignores unknown fields). */ +type StampedTokens = OAuthTokens & { expires_at?: number }; + +const secureKey = (serverId: string, key: string) => `mcp.${serverId}.${key}`; + +async function getJson(serverId: string, key: string): Promise { + try { + const raw = await SecureStore.getItemAsync(secureKey(serverId, key)); + return raw ? (JSON.parse(raw) as T) : undefined; + } catch { + return undefined; + } +} + +async function setJson(serverId: string, key: string, value: unknown): Promise { + await SecureStore.setItemAsync(secureKey(serverId, key), JSON.stringify(value)); +} + +async function del(serverId: string, key: string): Promise { + await SecureStore.deleteItemAsync(secureKey(serverId, key)).catch(() => {}); +} + +/** + * SDK OAuth provider backed by expo-secure-store. One instance per server. + * + * `redirectToAuthorization` is intentionally a no-op that STASHES the URL: the + * mobile flow drives the browser imperatively via `openAuthSessionAsync` (which + * both opens the page and returns the callback), so we don't need the SDK to + * open anything — we just need the authorize URL it built. `startAuth` reads it + * back off the provider. + */ +export class SecureStoreOAuthProvider implements OAuthClientProvider { + /** Authorize URL captured from the SDK's redirectToAuthorization call. */ + authorizationUrl: URL | null = null; + + /** + * CIMD client_id URL (SEP-991). The SDK uses this URL as the client_id — + * skipping Dynamic Client Registration — when the server advertises + * client_id_metadata_document_supported. Servers that don't support CIMD fall + * back to DCR via the SDK automatically. + */ + readonly clientMetadataUrl = CLIENT_METADATA_URL; + + constructor( + private readonly serverId: string, + private readonly serverName: string, + private readonly scope?: string, + ) {} + + get redirectUrl(): string { + // Advertise the https bounce page (strict providers reject cairn:// here). + return HTTPS_REDIRECT_URI; + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: `Cairn — ${this.serverName}`, + // Advertise the https bounce redirect (used by DCR fallback + must match + // the CIMD doc). The bounce page forwards to cairn://oauth/callback. + redirect_uris: [HTTPS_REDIRECT_URI], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + ...(this.scope ? { scope: this.scope } : {}), + }; + } + + async clientInformation(): Promise { + return getJson(this.serverId, KEY_CLIENT_INFO); + } + + async saveClientInformation(info: OAuthClientInformationFull): Promise { + await setJson(this.serverId, KEY_CLIENT_INFO, info); + } + + async tokens(): Promise { + return getJson(this.serverId, KEY_TOKENS); + } + + async saveTokens(tokens: OAuthTokens): Promise { + // Stamp an absolute expiry from the relative expires_in at write time, so + // getAccessToken can decide whether to refresh later. Additive; SDK ignores it. + const stamped: StampedTokens = + typeof tokens.expires_in === "number" + ? { ...tokens, expires_at: Date.now() + tokens.expires_in * 1000 } + : tokens; + await setJson(this.serverId, KEY_TOKENS, stamped); + } + + async saveCodeVerifier(verifier: string): Promise { + await setJson(this.serverId, KEY_VERIFIER, verifier); + } + + async codeVerifier(): Promise { + const v = await getJson(this.serverId, KEY_VERIFIER); + if (!v) throw new Error("Missing PKCE code verifier — restart the sign-in flow."); + return v; + } + + redirectToAuthorization(authorizationUrl: URL): void { + // Capture (don't open) — the imperative flow opens it via WebBrowser. + this.authorizationUrl = authorizationUrl; + } + + invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): void { + if (scope === "all" || scope === "client") void del(this.serverId, KEY_CLIENT_INFO); + if (scope === "all" || scope === "tokens") void del(this.serverId, KEY_TOKENS); + if (scope === "all" || scope === "verifier") void del(this.serverId, KEY_VERIFIER); + } +} + +export type AuthStartResult = + | { status: "authorized" } + | { status: "cancelled" } + // `desktopOnly` marks a failure caused by the provider refusing our mobile + // redirect at registration (it only allows a loopback/pre-registered client, + // e.g. Asana, Vercel) — the UI phrases these as "connect on desktop" rather + // than a scary raw error. + | { status: "error"; error: string; desktopOnly?: boolean }; + +/** + * Translate a raw SDK/OAuth error into a friendly message. Detects the + * "provider won't accept our mobile redirect / register" class (Asana-style + * invalid_redirect_uri, registration 4xx) and flags it desktopOnly so the caller + * can suggest desktop sign-in. Everything else gets a clean generic message + * (the raw SDK text is often a JSON-parse error on the provider's non-JSON body). + */ +function friendlyAuthError(serverName: string, raw: string): { error: string; desktopOnly: boolean } { + const r = raw.toLowerCase(); + const looksDesktopOnly = + r.includes("invalid_redirect_uri") || + r.includes("redirect uri") || + r.includes("redirect_uri") || + r.includes("/register") || // registration was rejected (non-JSON 400 etc.) + r.includes("registration"); + if (looksDesktopOnly) { + return { + error: `${serverName} doesn't allow sign-in from a phone — it only accepts a desktop connection. Connect it in the Cairn desktop app instead.`, + desktopOnly: true, + }; + } + return { + error: `Couldn't connect ${serverName}. Please try again, or connect it in the Cairn desktop app.`, + desktopOnly: false, + }; +} + +/** + * Begin (and complete) an interactive OAuth sign-in for an MCP server via CIMD. + * + * We use CIMD (URL-based client_id, no registration) with an https bounce + * redirect. This works for CIMD-capable providers whose redirect allowlist + * accepts our https bounce URL (e.g. Tavily). Providers that require pre- + * registration / an allow-listed redirect (e.g. Canva) can't be connected from + * mobile without a registered app and are treated as desktop-only — the Tools + * screen notes this. (We deliberately DON'T fall back to DCR: for those + * providers DCR still fails the redirect allowlist, so a fallback just adds a + * confusing second browser round-trip that also fails.) + * + * Flow: auth(serverUrl) → capture authorize URL → openAuthSessionAsync (server + * redirects to our https bounce page → forwards to cairn://oauth/callback, + * intercepted by the auth session) → parse the code → auth(authorizationCode) + * exchanges it and persists tokens. + * + * Never throws — returns a status the UI can show. + */ +export async function startAuth(cfg: OAuthServerConfig, serverName: string): Promise { + const provider = new SecureStoreOAuthProvider(cfg.id, serverName, cfg.scope); + + // Clear stale client registration + verifier (awaited) so auth()'s + // clientInformation() read starts fresh and the CIMD branch runs. + await del(cfg.id, KEY_CLIENT_INFO); + await del(cfg.id, KEY_VERIFIER); + + let step1: Awaited>; + try { + step1 = await auth(provider, { serverUrl: cfg.serverUrl, scope: cfg.scope }); + } catch (e) { + // Most commonly the provider rejecting our mobile redirect at registration + // (Asana/Vercel loopback-only). Surface a human message + desktopOnly flag. + return { status: "error", ...friendlyAuthError(serverName, e instanceof Error ? e.message : String(e)) }; + } + if (step1 === "AUTHORIZED") return { status: "authorized" }; + + const authUrl = provider.authorizationUrl; + if (!authUrl) return { status: "error", error: "No authorization URL was produced." }; + + let result: WebBrowser.WebBrowserAuthSessionResult; + try { + // Watch for the FINAL cairn:// scheme: the server redirects to our https + // bounce page, which forwards to cairn://oauth/callback — the in-app auth + // session intercepts that scheme and hands the code back. + result = await WebBrowser.openAuthSessionAsync(authUrl.toString(), OAUTH_REDIRECT_URI); + } catch (e) { + return { status: "error", error: e instanceof Error ? e.message : String(e) }; + } + if (result.type !== "success" || !("url" in result)) { + // "cancel"/"dismiss" — user backed out, OR the provider rejected the redirect + // / showed an in-page error (e.g. Canva's allowlist) and never redirected. + return { status: "cancelled" }; + } + + const cb = parseOAuthCallback(result.url); + if (!cb) return { status: "error", error: "The sign-in redirect was malformed (no authorization code)." }; + + try { + const done = await auth(provider, { + serverUrl: cfg.serverUrl, + authorizationCode: cb.code, + scope: cfg.scope, + }); + if (done !== "AUTHORIZED") return { status: "error", error: `Couldn't finish signing in to ${serverName}. Please try again.` }; + return { status: "authorized" }; + } catch (e) { + return { status: "error", ...friendlyAuthError(serverName, e instanceof Error ? e.message : String(e)) }; + } +} + +/** True if the server has stored tokens (i.e. has been connected). */ +export async function hasTokens(serverId: string): Promise { + return (await getJson(serverId, KEY_TOKENS)) != null; +} + +/** Forget every OAuth artefact for a server (sign out). */ +export async function signOut(serverId: string): Promise { + await del(serverId, KEY_CLIENT_INFO); + await del(serverId, KEY_TOKENS); + await del(serverId, KEY_VERIFIER); +} + +/** True if the stored access token is expired or within the refresh skew window. */ +function isTokenExpired(tokens: StampedTokens): boolean { + const expiresAt = tokens.expires_at; + if (typeof expiresAt !== "number") return false; // no absolute expiry → assume valid + return Date.now() >= expiresAt - TOKEN_REFRESH_SKEW_MS; +} + +/** + * Return a valid bearer access token for a server, refreshing it via the SDK + * `auth()` refresh grant if expired. Returns null when never authorized or a + * refresh fails (so the transport connects without an Authorization header and + * the caller can surface a re-auth prompt). Non-interactive: never opens a + * browser. + */ +export async function getAccessToken(cfg: OAuthServerConfig, serverName: string): Promise { + const provider = new SecureStoreOAuthProvider(cfg.id, serverName, cfg.scope); + const stored = (await provider.tokens()) as StampedTokens | undefined; + if (!stored?.access_token) return null; + if (!isTokenExpired(stored)) return stored.access_token; + if (!stored.refresh_token) return null; + try { + const result = await auth(provider, { serverUrl: cfg.serverUrl, scope: cfg.scope }); + if (result !== "AUTHORIZED") return null; + } catch { + return null; + } + return (await provider.tokens())?.access_token ?? null; +} diff --git a/mobile/src/chat/mcp-store.ts b/mobile/src/chat/mcp-store.ts new file mode 100644 index 00000000..fd127534 --- /dev/null +++ b/mobile/src/chat/mcp-store.ts @@ -0,0 +1,171 @@ +/** + * Installed MCP servers — the mobile side of the community registry's `mcp` + * connectors (Track 3). Parallel to services.ts, but for streamable-HTTP MCP + * servers instead of single HTTP calls. + * + * Storage split (device-global, unsynced): + * - The server config lives in the meta DB (getMeta/setMeta) as JSON. + * - A CACHE of each server's discovered tool defs (namespaced OpenAI defs) also + * lives in meta, so the model-facing tool list (allTools/toolsForAgent) stays + * SYNCHRONOUS. Discovery is a network call; we refresh the cache at connect / + * Settings-open time and read it synchronously when assembling tools. A tool + * absent from the cache simply isn't offered until the next refresh. + * - OAuth artefacts + API-key headers live in expo-secure-store (see mcp-oauth + * + per-server header resolution), never here. + */ + +import { getMeta, setMeta } from "../db"; +import type { RegistryMcpEntry } from "@cairn/shared/chat/registry-schema"; +import type { OpenAIToolDef } from "@cairn/shared/chat/mcp-namespace"; +import type { McpServerRuntimeConfig } from "./mcp-client"; +import * as mcpClient from "./mcp-client"; +import { signOut } from "./mcp-oauth"; + +const META_INSTALLED = "mcp.installed"; // JSON array of InstalledMcpServer +const META_TOOLCACHE = "mcp.toolcache"; // JSON map serverId -> OpenAIToolDef[] + +/** An installed MCP server: registry id/name + the connection-relevant config. */ +export interface InstalledMcpServer { + id: string; + name: string; + blurb?: string; + iconSvg?: string; // brand logo markup (registry, CI-sanitized) for ConnectorLogo + brandColor?: string; + baseUrl: string; + transport: "http" | "sse"; + authMode: "none" | "oauth"; + oauthScope?: string; + /** Static headers (e.g. API-key servers). May contain placeholders. */ + headers?: Record; +} + +// ── install store (meta DB) ─────────────────────────────────────────────────── + +export function listInstalledMcpServers(): InstalledMcpServer[] { + const raw = getMeta(META_INSTALLED); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as InstalledMcpServer[]) : []; + } catch { + return []; + } +} + +function writeInstalled(list: InstalledMcpServer[]): void { + setMeta(META_INSTALLED, JSON.stringify(list)); +} + +export function isMcpServerInstalled(id: string): boolean { + return listInstalledMcpServers().some((s) => s.id === id); +} + +export function getInstalledMcpServer(id: string): InstalledMcpServer | undefined { + return listInstalledMcpServers().find((s) => s.id === id); +} + +/** Install (or overwrite) a registry MCP entry. Does NOT connect or authorize. */ +export function installMcpServer(entry: RegistryMcpEntry): void { + const d = entry.definition; + const record: InstalledMcpServer = { + id: entry.id, + name: d.name || entry.id, + blurb: entry.blurb, + iconSvg: entry.iconSvg, + brandColor: entry.brandColor, + baseUrl: d.baseUrl, + transport: d.transport, + authMode: d.authMode ?? "none", + oauthScope: d.oauthScope, + headers: d.headers, + }; + const list = listInstalledMcpServers().filter((s) => s.id !== entry.id); + list.push(record); + writeInstalled(list); +} + +/** Uninstall a server: drop record, cached tools, OAuth artefacts, live conn. */ +export async function uninstallMcpServer(id: string): Promise { + writeInstalled(listInstalledMcpServers().filter((s) => s.id !== id)); + clearCachedTools(id); + await signOut(id).catch(() => {}); + await mcpClient.dispose(id).catch(() => {}); +} + +/** Map an installed server to the client runtime config. */ +export function toRuntimeConfig(s: InstalledMcpServer): McpServerRuntimeConfig { + return { + id: s.id, + baseUrl: s.baseUrl, + transport: s.transport, + headers: s.headers, + authMode: s.authMode, + oauthScope: s.oauthScope, + name: s.name, + }; +} + +// ── discovered-tool cache (meta DB) ─────────────────────────────────────────── + +function readCache(): Record { + const raw = getMeta(META_TOOLCACHE); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function writeCache(cache: Record): void { + setMeta(META_TOOLCACHE, JSON.stringify(cache)); +} + +/** Cached namespaced tool defs for ALL installed servers (sync — for the model). */ +export function getCachedMcpToolDefs(): OpenAIToolDef[] { + const cache = readCache(); + const installed = new Set(listInstalledMcpServers().map((s) => s.id)); + const out: OpenAIToolDef[] = []; + for (const [serverId, defs] of Object.entries(cache)) { + if (installed.has(serverId)) out.push(...defs); + } + return out; +} + +/** Cached namespaced tool defs for ONE server (for the per-server tool list). */ +export function getCachedMcpToolDefsForServer(serverId: string): OpenAIToolDef[] { + return readCache()[serverId] ?? []; +} + +function clearCachedTools(serverId: string): void { + const cache = readCache(); + if (serverId in cache) { + delete cache[serverId]; + writeCache(cache); + } +} + +/** + * Refresh (connect + listTools) the discovered-tool cache for one server. Called + * after connect/sign-in and when the Settings screen opens. Returns the count, + * or null on failure (cache left as-is). Never throws. + */ +export async function refreshServerTools(id: string): Promise { + const server = getInstalledMcpServer(id); + if (!server) return null; + const defs = await mcpClient.listTools(toRuntimeConfig(server)); + // listTools returns [] both for "no tools" and "connection failed"; only + // overwrite the cache when we actually got tools, so a transient failure + // doesn't wipe a previously-good list. + if (defs.length === 0) return null; + const cache = readCache(); + cache[id] = defs; + writeCache(cache); + return defs.length; +} + +/** Refresh every installed server's tool cache (best-effort, parallel). */ +export async function refreshAllServerTools(): Promise { + await Promise.all(listInstalledMcpServers().map((s) => refreshServerTools(s.id).catch(() => null))); +} diff --git a/mobile/src/chat/providers/apple.ts b/mobile/src/chat/providers/apple.ts index 7a2420b4..7a6e0551 100644 --- a/mobile/src/chat/providers/apple.ts +++ b/mobile/src/chat/providers/apple.ts @@ -39,8 +39,9 @@ import { type AppleTool, type AppleToolCallEvent, } from "@modules/apple-llm"; -import { TOOL_MAP } from "../tools"; +import { allToolMap } from "../tools"; import { getAppleReasoningLevel } from "../ai-config"; +import { parseToolArgs } from "@cairn/shared/chat/parse-tool-args"; import { type AiTool, type ChatProvider, @@ -255,16 +256,25 @@ function buildTools(tools: Record, server: boolean): AppleTool[] /** Execute a tool locally and return its result as a JSON string. */ async function runToolToJson(toolName: string, inputJson: string): Promise<{ resultJson: string; error?: string }> { - const tool = TOOL_MAP.get(toolName); + // Resolve against the FULL enabled tool set (built-ins + installed services), + // matching what buildTools advertised to the model. + const tool = allToolMap().get(toolName); if (!tool) { return { resultJson: JSON.stringify({ error: `Unknown tool: ${toolName}` }) }; } - let args: Record = {}; - try { - args = inputJson ? (JSON.parse(inputJson) as Record) : {}; - } catch { - args = {}; + // Tolerant, LOSSLESS parse of the model's argument JSON (strict-first, then a + // structure-only repair pass). Critically, a genuinely-unparseable payload is + // NOT run with `{}` — that silently wrote empty notes for malformed + // ensure_note/patch_note calls. Instead we surface a parse error the model can + // read and re-issue the call against. + const parsed = parseToolArgs(inputJson); + if (!parsed.ok) { + return { + resultJson: JSON.stringify({ error: parsed.error }), + error: parsed.error, + }; } + const args = parsed.value; try { // Tools may be sync or async (e.g. search_notes_semantic) — await either so // a Promise isn't stringified as "{}" and the real output lost. @@ -382,12 +392,11 @@ function makeStreamApple(server: boolean) { // Execute locally, surface for the UI tool-trail (as an already-executed // tool so agent.ts doesn't re-run it), then resolve back into the native // turn. Apple continues generating once resolved. - let input: unknown = {}; - try { - input = item.input ? JSON.parse(item.input) : {}; - } catch { - input = { _raw: item.input }; - } + // Parse for DISPLAY in the tool-trail (execution below re-parses via + // runToolToJson). Use the same tolerant parser so the shown args match + // what actually ran; fall back to a raw echo if even that fails. + const parsedInput = parseToolArgs(item.input); + const input: unknown = parsedInput.ok ? parsedInput.value : { _raw: item.input }; const { resultJson, error } = await runToolToJson(item.toolName, item.input); let output: unknown; try { diff --git a/mobile/src/chat/registry.ts b/mobile/src/chat/registry.ts new file mode 100644 index 00000000..906491bc --- /dev/null +++ b/mobile/src/chat/registry.ts @@ -0,0 +1,91 @@ +/** + * Community registry — fetches + caches the cairn-community manifest on-device. + * + * Mirrors electron/lib/community-registry.ts but for mobile: the SAME manifest + * (validated by the SAME shared parser) is fetched over HTTPS with a conditional + * GET (stored ETag), and cached in the DEVICE-GLOBAL meta DB (getMeta/setMeta) so + * a browse works offline and the catalog is available across every workspace. + * + * Pure validation lives in @cairn/shared/chat/registry-schema (Track 1); this + * module only adds the platform I/O (expo/fetch + meta-DB cache). Nothing here + * is synced — the registry is device-global config, not user content. + */ + +import { fetch as expoFetch } from "expo/fetch"; +import { parseManifest, type CommunityManifest, type RegistryServiceEntry } from "@cairn/shared/chat/registry-schema"; +import { getMeta, setMeta } from "../db"; + +/** Same source of truth as the desktop MANIFEST_URL. */ +const MANIFEST_URL = "https://raw.githubusercontent.com/ddutchie/cairn-community/main/manifest.json"; + +const META_MANIFEST = "registry.manifest"; // cached CommunityManifest JSON +const META_ETAG = "registry.etag"; // last ETag for conditional GET +const META_FETCHED_AT = "registry.fetchedAt"; // ISO of last successful fetch + +/** Give up on a slow/hung manifest fetch and fall back to the cache. */ +const FETCH_TIMEOUT_MS = 20_000; + +/** The cached manifest, or null if nothing has been fetched yet / cache is corrupt. */ +export function getCachedManifest(): CommunityManifest | null { + const raw = getMeta(META_MANIFEST); + if (!raw) return null; + try { + return parseManifest(JSON.parse(raw)); + } catch { + // A corrupt cache should never break browse — treat as "no cache". + return null; + } +} + +/** ISO timestamp of the last successful manifest fetch, or null. */ +export function getManifestFetchedAt(): string | null { + return getMeta(META_FETCHED_AT); +} + +/** + * Fetch the manifest with a conditional GET. On 304 (or any network error) the + * cached manifest is returned unchanged. On 200 the body is validated + cached. + * Never throws — returns { manifest, error? } so the UI can show a soft error + * while still rendering the cache. + */ +export async function fetchManifest(force = false): Promise<{ manifest: CommunityManifest | null; error?: string }> { + const cached = getCachedManifest(); + const etag = force ? null : getMeta(META_ETAG); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await expoFetch(MANIFEST_URL, { + method: "GET", + headers: etag ? { "If-None-Match": etag } : {}, + signal: controller.signal, + }); + if (res.status === 304) { + return { manifest: cached }; + } + if (!res.ok) { + return { manifest: cached, error: `Registry fetch failed (${res.status}).` }; + } + const text = await res.text(); + // Validate BEFORE caching so a malformed upstream never poisons the cache. + const manifest = parseManifest(JSON.parse(text)); + setMeta(META_MANIFEST, text); + const newEtag = res.headers.get("etag"); + if (newEtag) setMeta(META_ETAG, newEtag); + setMeta(META_FETCHED_AT, new Date().toISOString()); + return { manifest }; + } catch (e) { + const msg = controller.signal.aborted + ? "Registry fetch timed out." + : e instanceof Error + ? e.message + : String(e); + return { manifest: cached, error: msg }; + } finally { + clearTimeout(timer); + } +} + +/** Convenience: the service entries from the (cached or freshly-fetched) manifest. */ +export function getRegistryServices(): RegistryServiceEntry[] { + return getCachedManifest()?.services ?? []; +} diff --git a/mobile/src/chat/services.ts b/mobile/src/chat/services.ts new file mode 100644 index 00000000..ecc05655 --- /dev/null +++ b/mobile/src/chat/services.ts @@ -0,0 +1,246 @@ +/** + * Installed HTTP services — the mobile side of the community registry's + * `service` connectors (Track 2). An installed service is a stored HTTP call + * definition that the chat agent can invoke as a namespaced tool. + * + * Storage split (non-secret config in the meta DB, secrets in the keychain): + * - The non-secret definition lives in the DEVICE-GLOBAL meta DB + * (getMeta/setMeta) as a JSON array — local-only, never synced, so an + * installed service is available in EVERY workspace/project on this device. + * - The API key (secret) lives in expo-secure-store under `svc..apiKey`, + * never in SQLite and never synced. + * + * Pure request-shaping is reused wholesale from @cairn/shared/chat/service-exec + * (the SAME core the desktop custom-services.ts uses). This module only adds the + * platform I/O: expo/fetch + secure-store secret resolution + the meta-DB store. + * + * Track 2 supports authMode:"none" services only (an API-key header). OAuth + * services (authMode:"oauth") are deferred to Track 3 — install() rejects them. + */ + +import { fetch as expoFetch } from "expo/fetch"; +import * as SecureStore from "expo-secure-store"; +import { + serviceOperationsToOpenAI, + resolveOperation, + buildOperationRequest, + filterResponse, + parseToolDefinition, + type CustomServiceRuntimeConfig, +} from "@cairn/shared/chat/service-exec"; +import type { RegistryServiceEntry } from "@cairn/shared/chat/registry-schema"; +import { getMeta, setMeta } from "../db"; +import { clearToolToggle } from "./tool-toggles"; +import type { ToolDef } from "./tools"; + +const META_INSTALLED = "services.installed"; // JSON array of InstalledService + +/** Request timeout for a service call (ms). Keeps a hung endpoint from stalling chat. */ +const REQUEST_TIMEOUT_MS = 20_000; + +/** + * A service the user has installed. This is the registry entry's `definition` + * plus the registry `id`/`name` for display. Secrets are NOT stored here. + */ +export interface InstalledService { + id: string; // registry entry id (namespacing key) + name: string; // display name + blurb?: string; + iconSvg?: string; // brand logo markup (registry, CI-sanitized) for ConnectorLogo + brandColor?: string; + definition: RegistryServiceEntry["definition"]; +} + +const secureKey = (id: string) => `svc.${id}.apiKey`; + +// ── install store (meta DB) ─────────────────────────────────────────────────── + +/** All installed services (device-global). */ +export function listInstalledServices(): InstalledService[] { + const raw = getMeta(META_INSTALLED); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as InstalledService[]) : []; + } catch { + return []; + } +} + +function writeInstalled(list: InstalledService[]): void { + setMeta(META_INSTALLED, JSON.stringify(list)); +} + +/** Whether a registry service id is installed. */ +export function isServiceInstalled(id: string): boolean { + return listInstalledServices().some((s) => s.id === id); +} + +/** + * Install a registry `service` entry, storing its API key in the keychain. + * Track 2 only supports authMode:"none" (API-key header) — an oauth service is + * rejected here (Track 3). Passing an empty apiKey is allowed for services whose + * definition needs no key (authMode:none + no header placeholder). + */ +export async function installService(entry: RegistryServiceEntry, apiKey: string): Promise<{ ok: boolean; error?: string }> { + if (entry.definition.authMode === "oauth") { + return { ok: false, error: "OAuth services aren't supported on mobile yet." }; + } + const key = apiKey.trim(); + if (key) { + try { + await SecureStore.setItemAsync(secureKey(entry.id), key); + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + } + const record: InstalledService = { + id: entry.id, + name: entry.definition.name || entry.id, + blurb: entry.blurb, + iconSvg: entry.iconSvg, + brandColor: entry.brandColor, + definition: entry.definition, + }; + const list = listInstalledServices().filter((s) => s.id !== entry.id); + list.push(record); + writeInstalled(list); + return { ok: true }; +} + +/** Uninstall a service: drop its record, secret, and any toggle entries. */ +export async function uninstallService(id: string): Promise { + const list = listInstalledServices(); + const svc = list.find((s) => s.id === id); + writeInstalled(list.filter((s) => s.id !== id)); + await SecureStore.deleteItemAsync(secureKey(id)).catch(() => {}); + // Remove the toggle for EVERY operation's namespaced tool so a reinstall + // starts default-on (a multi-op service has one tool per operation). + if (svc) { + try { + for (const def of serviceOperationsToOpenAI(toRuntimeConfig(svc))) { + clearToolToggle(def.function.name); + } + } catch { + /* best-effort */ + } + } +} + +/** Read a service's stored API key (null if none). */ +function getServiceApiKey(id: string): Promise { + return SecureStore.getItemAsync(secureKey(id)).catch(() => null); +} + +// ── runtime: turn installed services into ToolDefs ──────────────────────────── + +/** Map an InstalledService's definition to the shared runtime config. */ +function toRuntimeConfig(svc: InstalledService): CustomServiceRuntimeConfig { + const d = svc.definition; + return { + id: svc.id, + // Legacy single-op fields (may be undefined for multi-op services). + apiUrl: d.apiUrl, + method: d.method, + toolDefinition: d.toolDefinition, + responseKeys: d.responseKeys, + // Multi-op fields. + baseUrl: d.baseUrl, + operations: d.operations, + headers: d.headers, + authMode: d.authMode, + }; +} + +/** + * Resolve a service's header template into concrete headers, substituting the + * `` placeholder with the stored secret. A header whose placeholder is + * unfilled (no key stored) is DROPPED rather than sent verbatim — mirrors the + * desktop resolveSecrets contract so a raw "" never reaches the wire. + */ +async function resolveHeaders(svc: InstalledService): Promise> { + const template = svc.definition.headers ?? {}; + const apiKey = await getServiceApiKey(svc.id); + const out: Record = {}; + for (const [name, value] of Object.entries(template)) { + if (value.includes("")) { + if (apiKey) out[name] = value.replaceAll("", apiKey); + // else: drop the header (missing secret) + } else { + out[name] = value; + } + } + return out; +} + +/** + * Execute one OPERATION of an installed service (by its namespaced tool name). + * Uses the shared operation resolver + request-shaping (resolveOperation + + * buildOperationRequest) + response filtering; adds only expo/fetch + secret + * resolution. Returns a plain object the agent serialises. Never throws. + */ +async function runService(svc: InstalledService, toolName: string, args: Record): Promise { + const cfg = toRuntimeConfig(svc); + const op = resolveOperation(cfg, toolName); + if (!op) return { error: `Unknown operation ${toolName} for ${svc.name}.` }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + // parseToolDefinition can throw on a malformed toolDefinition — keep it in + // the try so it resolves to the standard { error } path, never a throw. + const parameters = parseToolDefinition(op.toolDefinition).parameters; + const headers = await resolveHeaders(svc); + const { url, init } = buildOperationRequest(op, args, headers, parameters); + const res = await expoFetch(url, { + method: init.method, + headers: init.headers as Record, + body: typeof init.body === "string" ? init.body : undefined, + signal: controller.signal, + }); + if (!res.ok) { + return { error: `${svc.name} failed (${res.status}).` }; + } + const contentType = res.headers.get("content-type") ?? ""; + const body = contentType.includes("application/json") ? await res.json() : await res.text(); + return filterResponse(body, op.responseKeys); + } catch (e) { + if (controller.signal.aborted) return { error: `${svc.name} timed out.` }; + return { error: e instanceof Error ? e.message : String(e) }; + } finally { + clearTimeout(timer); + } +} + +/** + * The installed services as mobile ToolDefs — ONE per operation, ready to fold + * into TOOLS. Each tool name is namespaced (`svc____`) via the + * shared helper so it can't collide with a built-in or another service's tools. + */ +export function serviceToolDefs(): ToolDef[] { + const out: ToolDef[] = []; + for (const svc of listInstalledServices()) { + const cfg = toRuntimeConfig(svc); + for (const def of serviceOperationsToOpenAI(cfg)) { + const toolName = def.function.name; + out.push({ + name: toolName, + description: def.function.description, + params: JSON.stringify(def.function.parameters), + jsonSchema: def.function.parameters, + run: (args: Record) => runService(svc, toolName, args), + }); + } + } + return out; +} + +/** + * The namespaced tool defs (name + description) for ONE installed service — used + * by the Settings screen to list a service's operations with per-tool toggles. + */ +export function serviceOperationDefs(svc: InstalledService): { name: string; description: string }[] { + return serviceOperationsToOpenAI(toRuntimeConfig(svc)).map((d) => ({ + name: d.function.name, + description: d.function.description, + })); +} diff --git a/mobile/src/chat/tool-toggles.ts b/mobile/src/chat/tool-toggles.ts new file mode 100644 index 00000000..cf00709c --- /dev/null +++ b/mobile/src/chat/tool-toggles.ts @@ -0,0 +1,66 @@ +/** + * Tool toggles — device-global on/off switches for INSTALLED community services. + * Stored in the meta DB (getMeta/setMeta), so a toggle applies across EVERY + * workspace/project on this device and is never synced. + * + * Scope: only installed HTTP-service (and, later, MCP) tools are toggleable. + * Built-in Cairn tools are ALWAYS on and are never written here — they are the + * assistant itself, and this matches desktop, where gating only applies to + * external MCP servers / HTTP services (see electron/lib/external-tools.ts), + * never Cairn's own tools. The Settings screen therefore lists built-ins + * read-only and only puts switches on installed services. + * + * The enabled set gates what `allTools()` / `allToolMap()` expose for installed + * services, which keeps the PCC 32K context lean and lets a user silence a + * service they don't want the assistant to reach for. + */ + +import { getMeta, setMeta } from "../db"; + +const META_KEY = "tools.enabled"; // JSON: { [toolName: string]: boolean } + +function readMap(): Record { + const raw = getMeta(META_KEY); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function writeMap(map: Record): void { + setMeta(META_KEY, JSON.stringify(map)); +} + +/** + * Whether a tool is enabled. Tools default to ENABLED — a tool is only off when + * an explicit `false` has been written for it, so newly-added built-ins and + * freshly-installed services are on until the user turns them off. + */ +export function isToolEnabled(name: string): boolean { + const map = readMap(); + return map[name] !== false; +} + +/** Set a tool's enabled state (persists to the device-global meta DB). */ +export function setToolEnabled(name: string, enabled: boolean): void { + const map = readMap(); + map[name] = enabled; + writeMap(map); +} + +/** The full enabled map (explicit entries only; missing = default-on). */ +export function getToggleMap(): Record { + return readMap(); +} + +/** Drop a tool's toggle entry entirely (used when a service is uninstalled). */ +export function clearToolToggle(name: string): void { + const map = readMap(); + if (name in map) { + delete map[name]; + writeMap(map); + } +} diff --git a/mobile/src/chat/tools.ts b/mobile/src/chat/tools.ts index 42d9a986..f81a8b7b 100644 --- a/mobile/src/chat/tools.ts +++ b/mobile/src/chat/tools.ts @@ -10,7 +10,11 @@ import * as q from "@/db/queries"; import { semanticSearch, semanticSearchTasks, catchUpIndex, finalizeRanking, type SemanticHit } from "@/notes/embeddings"; import { isAppleEmbeddingsSupported } from "@modules/apple-embeddings"; -import { webSearch, webExtract } from "./web-tools"; +import { serviceToolDefs } from "./services"; +import { isToolEnabled } from "./tool-toggles"; +import { getCachedMcpToolDefs, getInstalledMcpServer, toRuntimeConfig } from "./mcp-store"; +import * as mcpClient from "./mcp-client"; +import { parseToolName } from "@cairn/shared/chat/mcp-namespace"; export interface ToolDef { name: string; @@ -350,29 +354,92 @@ export const TOOLS: ToolDef[] = [ ), run: (a) => q.tagTask(str(a.card_id), strArray(a.tag_names), tagMode(a.mode)), }, - { - name: "web_search", - description: - "Search the live web for CURRENT or external information the user's notes/tasks can't answer — news, docs, facts, product info, anything post-training. Returns a compact list of { title, url, snippet }. Use web_extract on a returned url to read a page in full. Requires a web-search API key configured in AI settings.", - params: '{ "query": string, "count"?: number }', - jsonSchema: obj({ query: S, count: { type: "number" } }, ["query"]), - run: (a) => webSearch(str(a.query), typeof a.count === "number" ? a.count : undefined), - }, - { - name: "web_extract", - description: - "Read the full cleaned content (markdown) of a single web page by URL — use after web_search to dig into a result, or when the user gives a link to summarise. Content is truncated to stay within context. Requires a Tavily API key (page extraction isn't available with Brave).", - params: '{ "url": string }', - jsonSchema: obj({ url: S }, ["url"]), - run: (a) => webExtract(str(a.url)), - }, ]; export const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t])); +/** + * Built-in tools that MUTATE the user's workspace (create/edit/delete/link/tag). + * Everything else is read-only (search/get/list) or reads external data + * (web_*). Used only by the Settings → Tools & Services screen to group the + * read-only built-in list into Read vs Write sections. Keep in sync when adding + * a mutating built-in tool. + */ +export const WRITE_TOOL_NAMES = new Set([ + "ensure_note", + "append_to_note", + "patch_note", + "rename_note", + "bulk_move_notes", + "move_note_to_project", + "delete_note", + "create_task", + "update_task", + "delete_task", + "link_note_to_task", + "unlink_note_from_task", + "bulk_update_task_status", + "tag_note", + "tag_task", +]); + +/** + * The FULL tool set exposed to the agent: the built-in TOOLS (ALWAYS on) plus + * any installed community `service` tools AND installed MCP-server tools that + * are enabled. + * + * Built-in tools are never toggled off — they ARE the assistant (turning off + * e.g. get_cairn_context or search_notes would silently break it), and this + * matches desktop, where the tool gating only ever applies to external MCP + * servers / HTTP services, never Cairn's own tools. Installed services + MCP + * tools are subject to the per-tool toggle map (default ON). + * + * MCP tool defs are read from the SYNCHRONOUS meta-DB cache (populated by + * mcp-store on connect / Settings-open) — discovery is a network call, so the + * model-facing list uses the last-known tools; execution reconnects as needed. + * + * Recomputed on each call (cheap) so a just-installed service/server or a flipped + * toggle takes effect on the very next agent run — no app restart. Service + MCP + * tools are namespaced so they can't collide with built-ins or each other. + */ +export function allTools(): ToolDef[] { + const services = serviceToolDefs().filter((t) => isToolEnabled(t.name)); + const mcp = mcpToolDefs().filter((t) => isToolEnabled(t.name)); + return [...TOOLS, ...services, ...mcp]; +} + +/** + * Cached MCP tool defs (from mcp-store) adapted to the mobile ToolDef shape. The + * `run` routes the namespaced call to the owning server via mcp-client.callTool. + */ +function mcpToolDefs(): ToolDef[] { + return getCachedMcpToolDefs().map((def) => ({ + name: def.function.name, + description: def.function.description, + params: JSON.stringify(def.function.parameters), + jsonSchema: def.function.parameters, + run: async (args: Record) => { + const parsed = parseToolName(def.function.name); + const server = parsed ? getInstalledMcpServer(parsed.serverId) : undefined; + if (!parsed || !server) { + return { error: `MCP server for ${def.function.name} is not installed.` }; + } + // callTool returns a string (text result or an error string); wrap so the + // agent serialises it consistently with other tools. + const text = await mcpClient.callTool(toRuntimeConfig(server), def.function.name, args); + return text; + }, + })); +} + +/** Name→ToolDef map over allTools() (built-ins + enabled services). */ +export function allToolMap(): Map { + return new Map(allTools().map((t) => [t.name, t])); +} + /** Tool defs in the /agent/chat shape: { name: { description, jsonSchema } }. */ export function toolsForAgent(): Record }> { const out: Record }> = {}; - for (const t of TOOLS) out[t.name] = { description: t.description, jsonSchema: t.jsonSchema }; + for (const t of allTools()) out[t.name] = { description: t.description, jsonSchema: t.jsonSchema }; return out; } diff --git a/mobile/src/chat/web-config.ts b/mobile/src/chat/web-config.ts deleted file mode 100644 index 1b68bab5..00000000 --- a/mobile/src/chat/web-config.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Web-search / web-extract configuration (local-only, on-device). - * - * Mirrors the split in ai-config.ts: - * - The chosen web provider (non-secret) lives in the DEVICE-GLOBAL meta DB - * (getMeta/setMeta) — local-only, never synced. - * - The API keys (secrets) live in expo-secure-store (iOS keychain / Android - * keystore), never in SQLite and never synced. Keys are stored per provider - * so a user can hold both a Tavily and a Brave key and switch between them. - * - * Two providers are supported for the `web_search` tool: - * - "tavily": LLM-optimised search + clean page extraction (search + extract). - * - "brave": Brave Search web index (search only). - * - * The `web_extract` tool is Tavily-only — Brave has no page-extraction endpoint — - * so extract falls back to a Tavily key when the active provider is Brave. See - * web-tools.ts for how this is resolved at call time. - */ - -import * as SecureStore from "expo-secure-store"; -import { getMeta, setMeta } from "../db"; - -const KEY_PROVIDER = "web.provider"; // "tavily" | "brave" -const SECURE_KEY_TAVILY = "web.tavily.apiKey"; // secure-store key -const SECURE_KEY_BRAVE = "web.brave.apiKey"; // secure-store key - -/** Which web backend the `web_search` tool uses. */ -export type WebProvider = "tavily" | "brave"; - -/** Default web provider when the user hasn't chosen one. */ -export const DEFAULT_WEB_PROVIDER: WebProvider = "tavily"; - -/** The user's chosen web provider (defaults to Tavily). */ -export function getWebProvider(): WebProvider { - const v = getMeta(KEY_PROVIDER); - return v === "brave" || v === "tavily" ? v : DEFAULT_WEB_PROVIDER; -} - -/** Persist the chosen web provider. */ -export function setWebProvider(provider: WebProvider): void { - setMeta(KEY_PROVIDER, provider); -} - -async function readSecure(key: string): Promise { - try { - return await SecureStore.getItemAsync(key); - } catch { - return null; - } -} - -async function writeSecure(key: string, value: string): Promise { - const trimmed = value.trim(); - if (!trimmed) { - await SecureStore.deleteItemAsync(key).catch(() => {}); - return; - } - await SecureStore.setItemAsync(key, trimmed); -} - -/** Read the Tavily API key from the keychain (null if none stored). */ -export function getTavilyApiKey(): Promise { - return readSecure(SECURE_KEY_TAVILY); -} - -/** Store (or clear, when empty) the Tavily API key in the keychain. */ -export function setTavilyApiKey(apiKey: string): Promise { - return writeSecure(SECURE_KEY_TAVILY, apiKey); -} - -/** Read the Brave API key from the keychain (null if none stored). */ -export function getBraveApiKey(): Promise { - return readSecure(SECURE_KEY_BRAVE); -} - -/** Store (or clear, when empty) the Brave API key in the keychain. */ -export function setBraveApiKey(apiKey: string): Promise { - return writeSecure(SECURE_KEY_BRAVE, apiKey); -} - -/** The API key for a given provider (null if unset). */ -export function getWebApiKey(provider: WebProvider): Promise { - return provider === "brave" ? getBraveApiKey() : getTavilyApiKey(); -} - -/** Whether web search is usable — the active provider has a key stored. */ -export async function isWebSearchConfigured(): Promise { - return (await getWebApiKey(getWebProvider())) != null; -} diff --git a/mobile/src/chat/web-tools.ts b/mobile/src/chat/web-tools.ts deleted file mode 100644 index c48a18b2..00000000 --- a/mobile/src/chat/web-tools.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Web search + extraction clients for the chat `web_search` / `web_extract` - * tools. Runs on-device via expo/fetch (no vendor SDK), mirroring the direct - * fetch pattern in models-dev.ts / providers/openai.ts. - * - * Two search providers (see web-config.ts): - * - Tavily (https://api.tavily.com) — POST /search (Bearer auth). LLM-ready - * snippets. Also powers /extract for the `web_extract` tool. - * - Brave (https://api.search.brave.com) — GET /res/v1/web/search - * (X-Subscription-Token header). Web index; search only. - * - * Results are deliberately COMPACT (title + url + short snippet, capped count): - * tool outputs are serialised into the model context via JSON.stringify in the - * agent loop, and the Apple on-device path has a ~4K-token window — returning - * full page bodies would blow the budget. `web_extract` is the opt-in path for - * reading a full page, and even it trims to a character cap. - */ - -import { fetch as expoFetch } from "expo/fetch"; -import { - getWebProvider, - getWebApiKey, - getTavilyApiKey, - type WebProvider, -} from "./web-config"; - -const TAVILY_SEARCH_URL = "https://api.tavily.com/search"; -const TAVILY_EXTRACT_URL = "https://api.tavily.com/extract"; -const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"; - -/** Hard cap on results returned to the model (keeps tool output compact). */ -const MAX_RESULTS = 5; -/** Hard cap on extracted page content length (chars) to protect the context. */ -const EXTRACT_CHAR_CAP = 8000; - -/** A single normalised search hit, provider-agnostic. */ -interface WebSearchHit { - title: string; - url: string; - snippet: string; -} - -/** Clamp a user-supplied count into [1, MAX_RESULTS]. */ -function clampCount(v: unknown): number { - const n = typeof v === "number" ? Math.floor(v) : NaN; - if (!Number.isFinite(n) || n <= 0) return MAX_RESULTS; - return Math.min(n, MAX_RESULTS); -} - -/** Trim + collapse whitespace and cap length so snippets stay compact. */ -function trimSnippet(s: unknown, cap = 500): string { - const text = (typeof s === "string" ? s : "").replace(/\s+/g, " ").trim(); - return text.length > cap ? `${text.slice(0, cap)}…` : text; -} - -/** Pull a human-readable error message out of a provider's JSON error body. */ -async function errorMessage(res: Response, fallback: string): Promise { - try { - const body = (await res.json()) as { - detail?: { error?: string } | string; - message?: string; - error?: string; - }; - const detail = - typeof body.detail === "object" ? body.detail?.error : body.detail; - return detail || body.message || body.error || fallback; - } catch { - return fallback; - } -} - -/** Tavily POST /search → normalised hits. */ -async function tavilySearch( - apiKey: string, - query: string, - count: number, -): Promise { - const res = await expoFetch(TAVILY_SEARCH_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - query, - search_depth: "advanced", - chunks_per_source: 3, - max_results: count, - }), - }); - if (!res.ok) throw new Error(await errorMessage(res, `Tavily search failed (${res.status})`)); - const data = (await res.json()) as { - results?: { title?: string; url?: string; content?: string }[]; - }; - return (data.results ?? []).map((r) => ({ - title: trimSnippet(r.title, 200), - url: typeof r.url === "string" ? r.url : "", - snippet: trimSnippet(r.content), - })); -} - -/** Brave GET /res/v1/web/search → normalised hits. */ -async function braveSearch( - apiKey: string, - query: string, - count: number, -): Promise { - const url = `${BRAVE_SEARCH_URL}?q=${encodeURIComponent(query)}&count=${count}`; - const res = await expoFetch(url, { - method: "GET", - headers: { Accept: "application/json", "X-Subscription-Token": apiKey }, - }); - if (!res.ok) throw new Error(await errorMessage(res, `Brave search failed (${res.status})`)); - const data = (await res.json()) as { - web?: { results?: { title?: string; url?: string; description?: string }[] }; - }; - return (data.web?.results ?? []).slice(0, count).map((r) => ({ - title: trimSnippet(r.title, 200), - url: typeof r.url === "string" ? r.url : "", - snippet: trimSnippet(r.description), - })); -} - -/** - * Run a web search with the user's configured provider. Returns compact hits or - * an { error } object the model can read and relay to the user (missing key, - * quota, network). Never throws. - */ -export async function webSearch(query: string, count?: number): Promise< - { provider: WebProvider; results: WebSearchHit[] } | { error: string } -> { - const q = query.trim(); - if (!q) return { error: "Empty search query." }; - const provider = getWebProvider(); - const apiKey = await getWebApiKey(provider); - if (!apiKey) { - return { - error: `No ${provider === "brave" ? "Brave" : "Tavily"} API key is set. Add one in Settings → AI → Web search.`, - }; - } - const n = clampCount(count); - try { - const results = - provider === "brave" - ? await braveSearch(apiKey, q, n) - : await tavilySearch(apiKey, q, n); - return { provider, results }; - } catch (e) { - return { error: e instanceof Error ? e.message : String(e) }; - } -} - -/** - * Extract the clean text/markdown of a web page. Tavily-only (Brave has no - * extraction endpoint), so this uses a Tavily key regardless of the active - * search provider — if only a Brave key is set, it returns a helpful error. - * Content is capped to protect the model context. Never throws. - */ -export async function webExtract(url: string): Promise< - { url: string; content: string } | { error: string } -> { - const target = url.trim(); - if (!target) return { error: "Empty URL." }; - const apiKey = await getTavilyApiKey(); - if (!apiKey) { - return { - error: - "Reading a page needs a Tavily API key (Brave doesn't support extraction). Add one in Settings → AI → Web search.", - }; - } - try { - const res = await expoFetch(TAVILY_EXTRACT_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ urls: target, extract_depth: "basic", format: "markdown" }), - }); - if (!res.ok) throw new Error(await errorMessage(res, `Tavily extract failed (${res.status})`)); - const data = (await res.json()) as { - results?: { url?: string; raw_content?: string }[]; - failed_results?: { url?: string; error?: string }[]; - }; - const hit = data.results?.[0]; - if (!hit?.raw_content) { - const failed = data.failed_results?.[0]?.error; - return { error: failed || "Couldn't extract content from that URL." }; - } - const content = hit.raw_content; - return { - url: hit.url || target, - content: - content.length > EXTRACT_CHAR_CAP - ? `${content.slice(0, EXTRACT_CHAR_CAP)}\n\n[…truncated]` - : content, - }; - } catch (e) { - return { error: e instanceof Error ? e.message : String(e) }; - } -} diff --git a/mobile/src/components/AiSettingsForm.tsx b/mobile/src/components/AiSettingsForm.tsx index ee1cf30e..8274757d 100644 --- a/mobile/src/components/AiSettingsForm.tsx +++ b/mobile/src/components/AiSettingsForm.tsx @@ -7,8 +7,8 @@ import { ScrollView, ActivityIndicator, } from "react-native"; -import { Stack } from "expo-router"; -import { Check, ShieldCheck, RefreshCw, Cpu, Apple, Brain, Globe } from "lucide-react-native"; +import { Stack, useRouter } from "expo-router"; +import { Check, ShieldCheck, RefreshCw, Cpu, Apple, Brain, Wrench, ChevronRight } from "lucide-react-native"; import { ICON_CHECK } from "@/components/toolbar-icons"; import { haptics, toolbarPress } from "@/haptics"; import { useTheme } from "@/theme"; @@ -27,15 +27,6 @@ import { setAppleReasoningLevel, type ProviderPref, } from "@/chat/ai-config"; -import { - getWebProvider, - setWebProvider, - getTavilyApiKey, - setTavilyApiKey, - getBraveApiKey, - setBraveApiKey, - type WebProvider, -} from "@/chat/web-config"; import { isRorkAvailable } from "@/chat/providers/rork";import { isAppleProviderAvailable, isAppleServerProviderAvailable, @@ -74,6 +65,7 @@ import { QuotaBar } from "./ai-settings/QuotaBar"; export function AiSettingsForm({ onClose }: { onClose: () => void }) { const t = useTheme(); const styles = useAiSettingsStyles(); + const router = useRouter(); const rorkBuiltIn = isRorkAvailable(); const appleAvailable = isAppleProviderAvailable(); // Which Apple model backs the "Apple Intelligence" option: PCC (user-facing) @@ -125,39 +117,17 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { const [fetchingModels, setFetchingModels] = useState(false); const [modelsError, setModelsError] = useState(null); - // Web search / extract config. Provider is synchronous (meta DB); the keys are - // async (secure store) so they load in the mount effect below alongside the - // OpenAI key. Both keys are held so switching provider doesn't lose the other. - const [webProvider, setWebProviderState] = useState(() => getWebProvider()); - const [tavilyKey, setTavilyKey] = useState(""); - const [braveKey, setBraveKey] = useState(""); - const [loadedTavilyKey, setLoadedTavilyKey] = useState(""); - const [loadedBraveKey, setLoadedBraveKey] = useState(""); - const [hadTavilyKey, setHadTavilyKey] = useState(false); - const [hadBraveKey, setHadBraveKey] = useState(false); - // Load the API key (async, from secure store) once on mount. The synchronous // provider/baseUrl/model values are already seeded above. useEffect(() => { let cancelled = false; - // Load all three secure-store keys together and only clear the loading state - // once every one has resolved, so the form never renders with some key - // fields still blank/half-populated. - Promise.all([getOpenAIApiKey(), getTavilyApiKey(), getBraveApiKey()]).then( - ([openai, tavily, brave]) => { - if (cancelled) return; - setHadKey(openai != null); - setApiKey(openai ?? ""); - setLoadedKey(openai ?? ""); - setHadTavilyKey(tavily != null); - setTavilyKey(tavily ?? ""); - setLoadedTavilyKey(tavily ?? ""); - setHadBraveKey(brave != null); - setBraveKey(brave ?? ""); - setLoadedBraveKey(brave ?? ""); - setLoading(false); - }, - ); + getOpenAIApiKey().then((openai) => { + if (cancelled) return; + setHadKey(openai != null); + setApiKey(openai ?? ""); + setLoadedKey(openai ?? ""); + setLoading(false); + }); return () => { cancelled = true; }; @@ -205,10 +175,6 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { // Only touch the keychain if the key field actually changed from what we // loaded — avoids a redundant write (and allows clearing it). if (apiKey !== loadedKey) await setOpenAIApiKey(apiKey); - // Web search: persist the chosen provider + any changed keys. - setWebProvider(webProvider); - if (tavilyKey !== loadedTavilyKey) await setTavilyApiKey(tavilyKey); - if (braveKey !== loadedBraveKey) await setBraveApiKey(braveKey); haptics.success(); // settings persisted onClose(); } finally { @@ -450,86 +416,22 @@ export function AiSettingsForm({ onClose }: { onClose: () => void }) { )} - - - - Web search - - - Lets the assistant search the live web and read pages — for news, docs, - and facts beyond your notes. Works with any chat provider above. - - - { - haptics.selection(); - setWebProviderState("tavily"); - }} - t={t} - styles={styles} - /> - { - haptics.selection(); - setWebProviderState("brave"); - }} - t={t} - styles={styles} - /> - - {webProvider === "tavily" ? ( - - ) : ( - <> - - {/* Extraction is Tavily-only, so a Tavily key is still useful - alongside Brave to let the assistant read full pages. */} - - - Brave covers search. Page reading (web_extract) needs a Tavily key — - add one above to let the assistant open a result in full. - - - )} - - - - Stored securely in the device keychain. Never synced or sent anywhere - except your chosen search provider. - + { + haptics.selection(); + router.push("/settings/tools"); + }} + accessibilityRole="button" + accessibilityLabel="Open Tools & Services" + > + + + Tools & Services + Connect web search (Tavily, Brave) and other services, and choose which tools the assistant can use. - + + )} diff --git a/mobile/src/components/ConnectorLogo.tsx b/mobile/src/components/ConnectorLogo.tsx new file mode 100644 index 00000000..b2c9b403 --- /dev/null +++ b/mobile/src/components/ConnectorLogo.tsx @@ -0,0 +1,64 @@ +import { useMemo } from "react"; +import { View, StyleSheet } from "react-native"; +import { SvgXml } from "react-native-svg"; +import { Server, Plug } from "lucide-react-native"; +import { useTheme } from "@/theme"; + +/** + * ConnectorLogo (mobile) — renders a community-registry connector's brand logo, + * mirroring the desktop component (src/components/settings/tools/ConnectorLogo). + * + * The logo arrives as inline SVG markup (`iconSvg`) already compiled + allowlist- + * sanitized by the cairn-community CI. We still run the same cheap defense-in- + * depth guard (`looksSafeSvg`) before rendering it via react-native-svg's SvgXml, + * so a compromised manifest can't inject anything unexpected. When `iconSvg` is + * absent or fails the guard we fall back to a generic glyph: a server mark for + * MCP servers, a plug for HTTP services (matching desktop's fallbacks). + */ +export interface ConnectorLogoProps { + iconSvg?: string; + kind?: "mcp" | "service"; + size?: number; + /** Brand colour from the manifest entry; tints the fallback glyph + wrapper. */ + color?: string; +} + +/** + * Defense-in-depth (matches desktop looksSafeSvg): accept only a plain + * element with none of the dangerous constructs. Anything else → fallback glyph. + */ +function looksSafeSvg(svg: string): boolean { + const s = svg.trim(); + if (/^]/i.test(s) === false || !/<\/svg>\s*$/i.test(s)) return false; + if (/ makeStyles(size), [size]); + const tint = color || t.textSecondary; + + if (iconSvg && looksSafeSvg(iconSvg)) { + return ( + + + + ); + } + const Glyph = kind === "service" ? Plug : Server; + return ( + + + + ); +} + +function makeStyles(size: number) { + return StyleSheet.create({ + wrap: { width: size, height: size, alignItems: "center", justifyContent: "center" }, + }); +} diff --git a/mobile/src/components/ai-settings/styles.ts b/mobile/src/components/ai-settings/styles.ts index b4f80d41..9847ff67 100644 --- a/mobile/src/components/ai-settings/styles.ts +++ b/mobile/src/components/ai-settings/styles.ts @@ -119,6 +119,22 @@ export function makeAiSettingsStyles(t: Theme) { modelChipActive: { backgroundColor: t.accent, borderColor: t.accent }, modelChipText: { ...typeScale.caption, color: t.textSecondary, flexShrink: 1 }, modelChipTextActive: { color: t.accentFg, fontWeight: "600" }, + + navRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + backgroundColor: t.surface2, + borderWidth: 1, + borderColor: t.border, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 12, + marginTop: 4, + }, + navRowMain: { flex: 1, gap: 2 }, + navRowTitle: { ...typeScale.control, color: t.textPrimary }, + navRowSub: { ...typeScale.caption, color: t.textSecondary }, }); } diff --git a/mobile/src/components/chat/ToolTrail.tsx b/mobile/src/components/chat/ToolTrail.tsx index 0c3b266b..96a71769 100644 --- a/mobile/src/components/chat/ToolTrail.tsx +++ b/mobile/src/components/chat/ToolTrail.tsx @@ -1,8 +1,9 @@ import { useMemo } from "react"; -import { View, Text, Pressable, StyleSheet } from "react-native"; -import { CheckCircle, ChevronRight } from "lucide-react-native"; +import { View, Text, Pressable, StyleSheet, Linking, ActivityIndicator } from "react-native"; +import { CheckCircle, ChevronRight, ExternalLink } from "lucide-react-native"; import { useRouter } from "expo-router"; import { prettifyToolLabel } from "@cairn/shared/ui/constants"; +import { isHttpUrl } from "@cairn/shared/chat/external-ref"; import { useTheme, type as typeScale, type Theme } from "@/theme"; import { haptics } from "@/haptics"; import type { ToolCall } from "@/db/chat-store"; @@ -20,6 +21,15 @@ export function ToolTrail({ tools }: { tools: ToolCall[] }) { {tools.map((tt, i) => { const label = prettifyToolLabel(tt.tool, { prettifyBare: true }); + if (tt.running) { + // Tool is executing — show a spinner chip until its result arrives. + return ( + + + {label}… + + ); + } if (tt.ref) { return ( ); } + if (tt.externalRef && isHttpUrl(tt.externalRef.url)) { + const er = tt.externalRef; + const chipLabel = er.title || hostOf(er.url); + return ( + { + haptics.impact(); + void Linking.openURL(er.url); + }} + > + + {chipLabel} + + + ); + } return ( @@ -50,6 +81,15 @@ export function ToolTrail({ tools }: { tools: ToolCall[] }) { ); } +/** Best-effort friendly host label for an external URL (e.g. "github.com"). */ +function hostOf(url: string): string { + try { + return new URL(url).host.replace(/^www\./, ""); + } catch { + return url; + } +} + function makeStyles(t: Theme) { return StyleSheet.create({ toolTrail: { flexDirection: "column", gap: 4, alignSelf: "flex-start" }, @@ -66,6 +106,7 @@ function makeStyles(t: Theme) { paddingVertical: 4, }, toolChipText: { ...typeScale.caption, color: t.textSecondary }, - toolChipLink: { color: t.accent }, + toolChipLink: { color: t.accent, maxWidth: 220 }, + spinner: { transform: [{ scale: 0.7 }], width: 10, height: 10 }, }); } diff --git a/mobile/src/db/chat-store.ts b/mobile/src/db/chat-store.ts index 666cf57d..82435ff4 100644 --- a/mobile/src/db/chat-store.ts +++ b/mobile/src/db/chat-store.ts @@ -20,8 +20,18 @@ export interface StoredMessage { export interface ToolCall { tool: string; ok: boolean; + /** Correlates the streaming "running" chip with its completed result. */ + id?: string; + /** True while the tool is executing (before its result arrives). Not persisted. */ + running?: boolean; /** Set when the tool created/returned a note or card, so the chip can open it. */ ref?: { kind: "note" | "card"; id: string }; + /** + * Set when a web/MCP/service tool result carries a linkable external artefact + * (a web-search hit, a docs page, …), so the chip can open it in the browser. + * https-guarded at extraction (shared/chat/external-ref). + */ + externalRef?: { url: string; title?: string; snippet?: string }; } interface ChatLocalRow { diff --git a/electron/lib/external-ref.test.ts b/shared/chat/external-ref.test.ts similarity index 100% rename from electron/lib/external-ref.test.ts rename to shared/chat/external-ref.test.ts diff --git a/shared/chat/external-ref.ts b/shared/chat/external-ref.ts new file mode 100644 index 00000000..5b769070 --- /dev/null +++ b/shared/chat/external-ref.ts @@ -0,0 +1,230 @@ +/** + * External-reference extraction for MCP-server / custom-HTTP-service tool + * results. + * + * Native Cairn tools (create_note, update_task, …) return a well-known shape, so + * the chat loop can attach a `cairnRef` that renders a clickable chip opening the + * note/task in-app. MCP and custom-service tools instead return arbitrary, + * vendor-specific JSON — but the useful artefact is almost always an external + * URL (a Confluence page, a Jira issue, a web-search hit, a GitHub PR, …). + * + * {@link extractExternalRef} is a best-effort, schema-less heuristic: it scans a + * tool's stringified output for the most linkable thing and returns a normalized + * `{ url, title?, snippet? }` the renderer can show as a browser-opening chip. + * It is intentionally conservative — only http(s) URLs are ever returned, and it + * gives up (returns undefined) rather than guess wildly, so the UI simply falls + * back to the plain "✓ ToolName" chip. + * + * Pure + dependency-free → unit-testable in plain Node. + */ + +export interface ExternalRef { + url: string; + title?: string; + snippet?: string; +} + +/** + * URL-bearing field names, in priority order. `html_url`/`webUrl`/`permalink` + * are the canonical "human" links on GitHub/Graph/Reddit etc.; a bare `url`/ + * `link`/`href` is the common fallback. `self` (Jira/Atlassian REST) is last + * because it is often an API URL rather than a browser one — still better than + * nothing when it is the only link present. + */ +const URL_KEYS = [ + "html_url", + "webUrl", + "web_url", + "permalink", + "browseUrl", + "browse_url", + "url", + "link", + "href", + "self", +] as const; + +/** Title-bearing field names, in priority order. */ +const TITLE_KEYS = ["title", "name", "subject", "summary", "headline", "displayName"] as const; + +/** Snippet/description field names, in priority order. */ +const SNIPPET_KEYS = ["snippet", "description", "excerpt", "abstract", "text", "content"] as const; + +/** Nested containers commonly holding a list of result objects. */ +const LIST_KEYS = ["results", "items", "data", "hits", "matches", "records", "value", "entries"] as const; + +/** Max depth to walk when hunting for a URL, to bound cost on huge payloads. */ +const MAX_DEPTH = 6; + +/** + * Extract a single best external reference from a tool's stringified output. + * Returns undefined when the output isn't JSON, holds no usable http(s) URL, or + * is a Cairn error object. + */ +export function extractExternalRef(output: string | undefined): ExternalRef | undefined { + if (!output || typeof output !== "string") return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + // Non-JSON output (plain text). Fall back to the first bare https URL in it. + return refFromLooseText(output); + } + // A Cairn tool error object ({ error: "…" }) carries nothing linkable. + if (isPlainObject(parsed) && typeof parsed.error === "string" && Object.keys(parsed).length === 1) { + return undefined; + } + return findRef(parsed, 0); +} + +/** + * Extract up to `max` external references from a list-shaped result (e.g. a web + * search returning many hits), so the UI can render a compact stack. Falls back + * to the single {@link extractExternalRef} when the payload isn't a list. + */ +export function extractExternalRefs(output: string | undefined, max = 3): ExternalRef[] { + if (!output) return []; + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + const one = refFromLooseText(output); + return one ? [one] : []; + } + const list = findList(parsed, 0); + if (list && list.length > 0) { + const refs: ExternalRef[] = []; + for (const item of list) { + const ref = findRef(item, 0); + if (ref && !refs.some((r) => r.url === ref.url)) refs.push(ref); + if (refs.length >= max) break; + } + if (refs.length > 0) return refs; + } + const single = findRef(parsed, 0); + return single ? [single] : []; +} + +// ── internals ──────────────────────────────────────────────────────────────── + +/** Depth-first search for the first usable URL, preferring shallow/priority keys. */ +function findRef(value: unknown, depth: number): ExternalRef | undefined { + if (depth > MAX_DEPTH) return undefined; + + if (Array.isArray(value)) { + for (const item of value) { + const ref = findRef(item, depth + 1); + if (ref) return ref; + } + return undefined; + } + + if (!isPlainObject(value)) return undefined; + + // 1. A direct URL field on this object (priority order). + const url = firstUrl(value); + if (url) { + return { + url, + title: firstString(value, TITLE_KEYS), + snippet: trimSnippet(firstString(value, SNIPPET_KEYS)), + }; + } + + // 2. Atlassian-style nested links: _links.webui / _links.self / links.html. + // Prefer the human page keys (webui/html) over a generic self/api URL. + const links = (value._links ?? value.links) as unknown; + if (isPlainObject(links)) { + const linkUrl = firstString(links, ["webui", "html"] as const) ?? firstUrl(links); + if (linkUrl && isHttpUrl(linkUrl)) { + return { + url: linkUrl, + title: firstString(value, TITLE_KEYS), + snippet: trimSnippet(firstString(value, SNIPPET_KEYS)), + }; + } + } + + // 3. Recurse into nested objects/arrays (lists first — that's where hits live). + for (const key of LIST_KEYS) { + if (key in value) { + const ref = findRef(value[key], depth + 1); + if (ref) return ref; + } + } + for (const v of Object.values(value)) { + if (isPlainObject(v) || Array.isArray(v)) { + const ref = findRef(v, depth + 1); + if (ref) return ref; + } + } + return undefined; +} + +/** Find the first list of objects under a common container key. */ +function findList(value: unknown, depth: number): unknown[] | undefined { + if (depth > MAX_DEPTH) return undefined; + if (Array.isArray(value)) return value; + if (!isPlainObject(value)) return undefined; + for (const key of LIST_KEYS) { + const v = value[key]; + if (Array.isArray(v) && v.length > 0) return v; + } + for (const v of Object.values(value)) { + if (isPlainObject(v)) { + const nested = findList(v, depth + 1); + if (nested) return nested; + } + } + return undefined; +} + +/** First http(s) URL among the known URL keys of an object. */ +function firstUrl(obj: Record): string | undefined { + for (const key of URL_KEYS) { + const v = obj[key]; + if (typeof v === "string" && isHttpUrl(v)) return v; + } + return undefined; +} + +function firstString(obj: Record, keys: readonly string[]): string | undefined { + for (const key of keys) { + const v = obj[key]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + return undefined; +} + +/** Only http(s) — never javascript:, data:, file:, etc. */ +export function isHttpUrl(value: string): boolean { + if (typeof value !== "string") return false; + let u: URL; + try { + u = new URL(value); + } catch { + return false; + } + return u.protocol === "https:" || u.protocol === "http:"; +} + +const HTTPS_RE = /\bhttps?:\/\/[^\s"'<>)\]]+/; +/** Pull the first bare http(s) URL out of a plain-text (non-JSON) tool result. */ +function refFromLooseText(text: string): ExternalRef | undefined { + const m = HTTPS_RE.exec(text); + if (!m) return undefined; + // Strip common trailing punctuation the regex may have swept up. + const url = m[0].replace(/[.,;:]+$/, ""); + return isHttpUrl(url) ? { url } : undefined; +} + +function trimSnippet(s: string | undefined): string | undefined { + if (!s) return undefined; + const clean = s.replace(/\s+/g, " ").trim(); + if (!clean) return undefined; + return clean.length > 160 ? `${clean.slice(0, 157)}…` : clean; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} diff --git a/shared/chat/mcp-namespace.test.ts b/shared/chat/mcp-namespace.test.ts new file mode 100644 index 00000000..cf74e529 --- /dev/null +++ b/shared/chat/mcp-namespace.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { + namespaceToolName, + parseToolName, + isMcpToolName, + mcpToolsToOpenAI, + stringifyToolResult, +} from "./mcp-namespace"; + +describe("mcp namespacing", () => { + it("namespaces a tool name", () => { + expect(namespaceToolName("srv1", "search")).toBe("mcp__srv1__search"); + }); + + it("round-trips namespace ↔ parse", () => { + const ns = namespaceToolName("github", "create_issue"); + expect(parseToolName(ns)).toEqual({ serverId: "github", toolName: "create_issue" }); + }); + + it("parses a tool name whose own name contains the separator", () => { + const ns = namespaceToolName("srv1", "weird__tool__name"); + expect(parseToolName(ns)).toEqual({ serverId: "srv1", toolName: "weird__tool__name" }); + }); + + it("returns null for non-MCP names", () => { + expect(parseToolName("read")).toBeNull(); + expect(parseToolName("svc__service1__call")).toBeNull(); // service prefix, not mcp + }); + + it("returns null for malformed namespaced names", () => { + expect(parseToolName("mcp__")).toBeNull(); + expect(parseToolName("mcp__srv1__")).toBeNull(); // empty tool name + expect(parseToolName("mcp____tool")).toBeNull(); // empty server id + }); + + it("isMcpToolName distinguishes sources", () => { + expect(isMcpToolName("mcp__srv1__search")).toBe(true); + expect(isMcpToolName("svc__s1__call")).toBe(false); + expect(isMcpToolName("read")).toBe(false); + }); +}); + +describe("mcp def conversion", () => { + it("converts MCP tool defs to namespaced OpenAI defs", () => { + const defs = mcpToolsToOpenAI("srv1", [ + { name: "search", description: "Search things", inputSchema: { type: "object", properties: { q: { type: "string" } } } }, + ]); + expect(defs).toEqual([ + { + type: "function", + function: { + name: "mcp__srv1__search", + description: "Search things", + parameters: { type: "object", properties: { q: { type: "string" } } }, + }, + }, + ]); + }); + + it("supplies an empty object schema + description for sparse defs", () => { + const defs = mcpToolsToOpenAI("srv1", [{ name: "ping" }]); + expect(defs[0].function.description).toBe(""); + expect(defs[0].function.parameters).toEqual({ type: "object", properties: {} }); + }); +}); + +describe("stringifyToolResult", () => { + it("concatenates text content parts", () => { + expect(stringifyToolResult({ content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] })).toBe("a\nb"); + }); + + it("prefixes error results", () => { + expect(stringifyToolResult({ content: [{ type: "text", text: "boom" }], isError: true })).toBe("Error: boom"); + }); + + it("JSON-stringifies non-text parts", () => { + const out = stringifyToolResult({ content: [{ type: "image", data: "xyz" } as never] }); + expect(out).toContain("image"); + }); + + it("falls back to JSON for a non-standard shape", () => { + expect(stringifyToolResult({ weird: true })).toBe(JSON.stringify({ weird: true })); + }); +}); diff --git a/shared/chat/mcp-namespace.ts b/shared/chat/mcp-namespace.ts new file mode 100644 index 00000000..5f2a094d --- /dev/null +++ b/shared/chat/mcp-namespace.ts @@ -0,0 +1,86 @@ +/** + * MCP tool namespacing + shape conversion — the PURE helpers shared by the + * desktop (electron/lib/mcp-client.ts) and mobile (mobile/src/chat/mcp-client.ts) + * MCP clients, so both namespace tools, convert MCP tool defs to OpenAI function + * defs, and stringify tool results IDENTICALLY. + * + * Framework-free: no SDK import, no I/O. The platform clients own the transport, + * connection cache, and auth; they call these for the pure bits. + */ + +/** Namespacing prefix so MCP tools never collide with built-ins or each other. */ +const NS_PREFIX = "mcp__"; +const NS_SEP = "__"; + +/** Build the namespaced tool name exposed to the model: mcp____. */ +export function namespaceToolName(serverId: string, toolName: string): string { + return `${NS_PREFIX}${serverId}${NS_SEP}${toolName}`; +} + +/** + * Parse a namespaced tool name back into { serverId, toolName }. Returns null if + * the name is not an MCP-namespaced name (so callers can fall through to other + * tool sources), or if it is malformed (empty server id or tool name). + */ +export function parseToolName(namespaced: string): { serverId: string; toolName: string } | null { + if (!namespaced.startsWith(NS_PREFIX)) return null; + const rest = namespaced.slice(NS_PREFIX.length); + const sep = rest.indexOf(NS_SEP); + if (sep <= 0 || sep >= rest.length - NS_SEP.length) return null; + return { + serverId: rest.slice(0, sep), + toolName: rest.slice(sep + NS_SEP.length), + }; +} + +/** True if a tool name belongs to an MCP server (vs a built-in or service). */ +export function isMcpToolName(name: string): boolean { + return name.startsWith(NS_PREFIX); +} + +/** OpenAI function-calling tool definition shape. */ +export interface OpenAIToolDef { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +/** Minimal shape of an MCP tool definition as returned by listTools(). */ +export interface McpToolDef { + name: string; + description?: string; + inputSchema?: Record; +} + +/** + * Convert a server's MCP tool list into namespaced OpenAI function defs. Tools + * with no input schema get an empty-object schema (the model still must call + * with `{}`). + */ +export function mcpToolsToOpenAI(serverId: string, tools: McpToolDef[]): OpenAIToolDef[] { + return tools.map((t) => ({ + type: "function" as const, + function: { + name: namespaceToolName(serverId, t.name), + description: t.description ?? "", + parameters: t.inputSchema ?? { type: "object", properties: {} }, + }, + })); +} + +/** + * Collapse an MCP CallToolResult content array into a single string. Text parts + * are concatenated; non-text parts are JSON-stringified. An `isError` result is + * prefixed with "Error: " so the model can react to it. + */ +export function stringifyToolResult(res: unknown): string { + const r = res as { content?: Array<{ type?: string; text?: string }>; isError?: boolean }; + if (!r || !Array.isArray(r.content)) return JSON.stringify(res); + const text = r.content + .map((c) => (c.type === "text" && typeof c.text === "string" ? c.text : JSON.stringify(c))) + .join("\n"); + return r.isError ? `Error: ${text}` : text; +} diff --git a/shared/chat/oauth-callback.test.ts b/shared/chat/oauth-callback.test.ts new file mode 100644 index 00000000..1560dfd2 --- /dev/null +++ b/shared/chat/oauth-callback.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { parseOAuthCallback } from "./oauth-callback"; + +describe("parseOAuthCallback", () => { + it("parses a well-formed callback with state", () => { + expect(parseOAuthCallback("cairn://oauth/callback?code=abc&state=xyz")).toEqual({ code: "abc", state: "xyz" }); + }); + + it("tolerates the triple-slash form", () => { + expect(parseOAuthCallback("cairn:///oauth/callback?code=a&state=b")).toEqual({ code: "a", state: "b" }); + }); + + it("accepts an empty state (state=) — required for Tavily-style callbacks", () => { + // Real device capture: cairn://oauth/callback?code=…&state= + expect(parseOAuthCallback("cairn://oauth/callback?code=FIgV_E8&state=")).toEqual({ code: "FIgV_E8", state: "" }); + }); + + it("accepts a missing state entirely (state is optional in OAuth 2.1)", () => { + expect(parseOAuthCallback("cairn://oauth/callback?code=abc")).toEqual({ code: "abc", state: "" }); + }); + + it("requires the authorization code", () => { + expect(parseOAuthCallback("cairn://oauth/callback?state=b")).toBeNull(); + expect(parseOAuthCallback("cairn://oauth/callback")).toBeNull(); + }); + + it("rejects wrong scheme / path / deeper routes", () => { + expect(parseOAuthCallback("https://oauth/callback?code=a")).toBeNull(); + expect(parseOAuthCallback("cairn://oauth/other?code=a")).toBeNull(); + expect(parseOAuthCallback("cairn://evil/oauth/callback?code=a")).toBeNull(); + expect(parseOAuthCallback("cairn://oauth/oauth/callback?code=a")).toBeNull(); + }); + + it("rejects non-cairn / garbage", () => { + expect(parseOAuthCallback("not a url")).toBeNull(); + expect(parseOAuthCallback("")).toBeNull(); + }); +}); diff --git a/shared/chat/oauth-callback.ts b/shared/chat/oauth-callback.ts new file mode 100644 index 00000000..6a17760c --- /dev/null +++ b/shared/chat/oauth-callback.ts @@ -0,0 +1,54 @@ +/** + * OAuth deep-link callback parsing — shared by desktop (electron/lib/mcp-oauth) + * and mobile (mobile/src/chat/mcp-oauth) so both parse the `cairn://oauth/ + * callback?code=…&state=…` redirect IDENTICALLY. Pure + framework-free. + * + * Mobile reuses the SAME cairn:// scheme desktop registers (declared in + * mobile/app.json), so this one parser serves both platforms. + */ + +/** Custom protocol scheme Cairn registers for deep links. */ +export const DEEP_LINK_SCHEME = "cairn"; + +/** The redirect target advertised to authorization servers on the deep-link path. */ +export const OAUTH_REDIRECT_URI = "cairn://oauth/callback"; + +export interface OAuthCallback { + code: string; + /** + * The OAuth `state` echoed back, or "" when the authorization request didn't + * include one. State is OPTIONAL in OAuth 2.1 and the MCP SDK's auth() flow + * doesn't always send it (observed: Tavily returns `state=` empty), so a + * missing/empty state must NOT invalidate an otherwise-valid callback — the + * `code` is what the token exchange needs. Callers that DO use state to + * correlate a pending attempt (desktop's deep-link registry) should treat "" + * as "no state" rather than assume it's present. + */ + state: string; +} + +/** + * Parse a `cairn://oauth/callback?code=…[&state=…]` deep link. Returns null only + * when the URL isn't a well-formed callback (wrong scheme/host/path, or missing + * the `code`) so unrelated deep links are ignored. `state` is optional (see + * OAuthCallback). Tolerant of host vs. path styles (`cairn://oauth/callback` and + * `cairn:///oauth/callback`). + */ +export function parseOAuthCallback(rawUrl: string): OAuthCallback | null { + if (typeof rawUrl !== "string" || !rawUrl.startsWith(`${DEEP_LINK_SCHEME}://`)) return null; + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + // Accept only the two documented forms, exactly: + // cairn://oauth/callback → host="oauth" pathname="/callback" → "oauth/callback" + // cairn:///oauth/callback → host="" pathname="/oauth/callback" → "/oauth/callback" + // Reject any deeper route that merely ends with oauth/callback. + const path = `${url.host}${url.pathname}`.replace(/\/+$/, ""); + if (path !== "oauth/callback" && path !== "/oauth/callback") return null; + const code = url.searchParams.get("code"); + if (!code) return null; // code is the only truly-required param + return { code, state: url.searchParams.get("state") ?? "" }; +} diff --git a/electron/lib/parse-tool-args.test.ts b/shared/chat/parse-tool-args.test.ts similarity index 100% rename from electron/lib/parse-tool-args.test.ts rename to shared/chat/parse-tool-args.test.ts diff --git a/shared/chat/parse-tool-args.ts b/shared/chat/parse-tool-args.ts new file mode 100644 index 00000000..7e85af74 --- /dev/null +++ b/shared/chat/parse-tool-args.ts @@ -0,0 +1,148 @@ +/** + * Tolerant parser for LLM-emitted tool-call `arguments` JSON. + * + * The streamed `tool_calls[].function.arguments` string is supposed to be strict + * JSON, but models frequently emit *nearly*-valid JSON — especially for tools + * like `ensure_note`/`patch_note` whose `content` field carries markdown. + * + * `parseToolArgs` first attempts a strict `JSON.parse`. If that fails it applies + * a small set of repairs and retries. On total failure it NEVER silently returns + * `{}`: callers must surface a parse error so the model can re-issue the call. + * + * ── Repair safety contract (critical) ──────────────────────────────────────── + * Field VALUES — above all `content` — are user/model data and MUST round-trip + * byte-identically. A repair is only permitted if it is *provably lossless* for + * every string value. We therefore only repair things that are unambiguously + * malformed JSON *structure*, never anything that could alter the decoded string: + * + * 1. Unescaped control characters INSIDE a string literal (a real newline/tab + * typed straight into `"content":"line1line2"` instead of `\n`). This is + * illegal JSON (RFC 8259 §7), and escaping it to `\n`/`\t`/… decodes back to + * the byte-identical character — lossless. This is the dominant note-write + * breakage. + * 2. Trailing commas before a closing `}`/`]`, detected OUTSIDE string context + * so a literal comma inside content (e.g. "a,}") is never touched. + * + * Explicitly NOT repaired, because they would mutate content: + * - Stripping a wrapping ```code fence``` — indistinguishable from content that + * legitimately begins and ends with a fence; stripping would corrupt the note. + * - Rewriting smart/curly quotes to ASCII — the curly quotes may be genuine + * content ("AI theater"); rewriting them corrupts the note. + * If the JSON is broken in a way these two safe repairs can't fix, we fail loudly + * rather than guess and risk silently changing what the user's note says. + */ + +export type ParseToolArgsResult = + | { ok: true; value: Record; repaired: boolean } + | { ok: false; error: string }; + +/** + * Single context-aware repair pass. Walks the text tracking string context and + * escape state, applying ONLY the two lossless repairs above: + * - inside a string: escape raw control chars (< U+0020) to their JSON escape. + * - outside a string: drop a comma that is followed only by whitespace and then + * a `}` or `]` (a trailing comma). + * + * Because we never alter the decoded text of any string value, `JSON.parse` of + * the result yields byte-identical string values to what the model intended. + */ +function repairJson(src: string): string { + let out = ""; + let inString = false; + let escaped = false; + + for (let i = 0; i < src.length; i++) { + const ch = src[i]; + + if (inString) { + if (escaped) { + // Part of an escape sequence — pass through verbatim. + out += ch; + escaped = false; + continue; + } + if (ch === "\\") { + out += ch; + escaped = true; + continue; + } + if (ch === '"') { + out += ch; + inString = false; + continue; + } + const code = src.charCodeAt(i); + if (code < 0x20) { + // Raw control char inside a string → escape (lossless). + if (ch === "\n") out += "\\n"; + else if (ch === "\t") out += "\\t"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\b") out += "\\b"; + else if (ch === "\f") out += "\\f"; + else out += "\\u" + code.toString(16).padStart(4, "0"); + continue; + } + out += ch; + continue; + } + + // ── Outside a string ── + if (ch === '"') { + inString = true; + out += ch; + continue; + } + if (ch === ",") { + // Look ahead past whitespace: if the next non-space char is } or ], this is + // a trailing comma → drop it. Structural-only; string content is unreachable + // here because commas inside strings are handled by the inString branch. + let j = i + 1; + while (j < src.length && (src[j] === " " || src[j] === "\t" || src[j] === "\n" || src[j] === "\r")) j++; + if (j < src.length && (src[j] === "}" || src[j] === "]")) { + continue; // skip the comma + } + out += ch; + continue; + } + out += ch; + } + + return out; +} + +/** + * Parse LLM tool-call arguments with a strict-first, repair-fallback strategy. + * + * @param raw The assembled `function.arguments` string (may be empty/whitespace). + * @returns A discriminated result. `repaired: true` means the strict parse failed + * but the lossless repair pass succeeded — useful for telemetry. + */ +export function parseToolArgs(raw: string | null | undefined): ParseToolArgsResult { + const trimmed = (raw ?? "").trim(); + // Empty args is a legitimate no-arg tool call. + if (trimmed === "") return { ok: true, value: {}, repaired: false }; + + // 1. Strict parse — the happy path for well-behaved models. Well-formed JSON + // (including content that contains code fences, quotes, etc.) succeeds here + // untouched, so nothing is ever mutated on the common path. + try { + const value = JSON.parse(trimmed); + if (value && typeof value === "object" && !Array.isArray(value)) { + return { ok: true, value: value as Record, repaired: false }; + } + return { ok: false, error: `tool arguments must be a JSON object, got ${Array.isArray(value) ? "array" : typeof value}` }; + } catch (strictErr) { + // 2. Lossless repair pass — only fixes malformed JSON structure, never the + // decoded content of any string value. + try { + const value = JSON.parse(repairJson(trimmed)); + if (value && typeof value === "object" && !Array.isArray(value)) { + return { ok: true, value: value as Record, repaired: true }; + } + } catch { + /* fall through to structured failure */ + } + + return { ok: false, error: `malformed tool-call arguments JSON from model: ${(strictErr as Error).message}` }; + } +} diff --git a/shared/chat/registry-schema.test.ts b/shared/chat/registry-schema.test.ts new file mode 100644 index 00000000..e77c2a79 --- /dev/null +++ b/shared/chat/registry-schema.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { parseManifest } from "./registry-schema"; + +/** + * Minimal envelope for a services-only manifest. parseManifest drops individual + * malformed entries, so these tests assert an entry survives (valid) or is + * dropped (invalid) rather than throwing. + */ +function manifestWith(serviceDefinition: Record) { + return { + version: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + mcpServers: [], + services: [ + { + id: "svc", + author: "cairn", + version: "1.0.0", + tags: [], + blurb: "test", + definition: serviceDefinition, + }, + ], + }; +} + +const baseOp = { + name: "op", + method: "GET" as const, + toolDefinition: '{"name":"op","parameters":{"type":"object","properties":{}}}', +}; + +describe("registry-schema operation path validation", () => { + it("accepts a relative path with placeholders", () => { + const m = parseManifest( + manifestWith({ + name: "S", + baseUrl: "https://api.example.com", + operations: [{ ...baseOp, path: "/repos/{owner}/{repo}" }], + enabled: true, + }) + ); + expect(m.services).toHaveLength(1); + expect(m.services[0].definition.operations?.[0].path).toBe("/repos/{owner}/{repo}"); + }); + + it("accepts an empty/absent path", () => { + const m = parseManifest( + manifestWith({ + name: "S", + baseUrl: "https://api.example.com", + operations: [baseOp], + enabled: true, + }) + ); + expect(m.services).toHaveLength(1); + }); + + it.each([ + ["absolute URL", "https://evil.com/steal"], + ["scheme-relative", "//evil.com/steal"], + ["embedded scheme", "/x/https://evil.com"], + ])("drops an operation path that could escape the origin (%s)", (_label, path) => { + const m = parseManifest( + manifestWith({ + name: "S", + baseUrl: "https://api.example.com", + operations: [{ ...baseOp, path }], + enabled: true, + }) + ); + // The whole service entry is dropped because its operation failed to parse. + expect(m.services).toHaveLength(0); + }); +}); diff --git a/shared/chat/registry-schema.ts b/shared/chat/registry-schema.ts new file mode 100644 index 00000000..6493c8d9 --- /dev/null +++ b/shared/chat/registry-schema.ts @@ -0,0 +1,216 @@ +/** + * Community registry — shared manifest TYPES + Zod validation + parseManifest. + * + * The cairn-community manifest (the catalog of installable MCP servers + HTTP + * services) is validated the SAME way on every platform, so this schema-and-type + * core is shared. Platform-specific I/O (how the manifest is fetched + cached) + * stays per-platform: + * - desktop: electron/lib/community-registry.ts (node fetch + ETag + fs cache) + * - mobile: (Track 2) expo/fetch + a cached row in the meta DB + * + * Validation is fail-soft at the ENTRY level: `parseManifest` validates the + * envelope strictly but drops individual malformed entries rather than rejecting + * the whole catalog, so one bad community contribution can't blank the list. + * + * Dependency: `zod` only (present on both desktop + mobile at the same major). + */ + +import * as z from "zod"; + +// ── Types (mirror src/types/index.ts; wired to the renderer by IPC strings) ── + +export 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; +} + +export interface RegistryMcpEntry extends RegistryEntryMeta { + definition: { + name: string; + description?: string; + transport: "sse" | "http"; + baseUrl: string; + headers?: Record; + authMode?: "none" | "oauth"; + oauthScope?: string; + disabledTools?: string[]; + enabled: boolean; + }; +} + +/** One operation of a multi-operation HTTP service. */ +export interface RegistryServiceOperation { + name: string; + description?: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + path?: string; + toolDefinition: string; + paramLocations?: Record; + query?: Record; + responseKeys?: string[]; +} + +export interface RegistryServiceEntry extends RegistryEntryMeta { + definition: { + name: string; + description?: string; + /** Legacy single-op endpoint. Use baseUrl + operations for multi-op. */ + apiUrl?: string; + method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + /** Legacy single-op tool. */ + toolDefinition?: string; + /** Base URL shared by all operations (multi-op). */ + baseUrl?: string; + /** Multi-operation definition — each becomes its own namespaced tool. */ + operations?: RegistryServiceOperation[]; + responseKeys?: string[]; + apiKeyUrl?: string; + authMode?: "none" | "oauth"; + oauth?: { serverUrl?: string; scope?: string; clientId?: string; authorizationUrl?: string; tokenUrl?: string }; + enabled: boolean; + }; +} + +export interface CommunityManifest { + version: number; + updatedAt: string; + mcpServers: RegistryMcpEntry[]; + services: RegistryServiceEntry[]; +} + +// ── 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 method = z.enum(["GET", "POST", "PUT", "DELETE"]); +const paramLocations = z.record(z.string(), z.enum(["path", "query", "body"])).optional(); + +/** + * An operation `path` must be RELATIVE — it is appended to the service baseUrl, + * so it must not carry its own scheme/host or the request could be redirected to + * a different origin than the one shown + trusted at install time. Reject absolute + * URLs ("https://…"), scheme-relative values ("//host") and any embedded scheme. + */ +const relativePath = z + .string() + .refine( + (p) => !/^[a-z][a-z0-9+.-]*:\/\//i.test(p) && !p.startsWith("//") && !p.includes("://"), + { message: "operation path must be relative to baseUrl (no scheme or host)" } + ); + +const serviceOperation = z.object({ + name: z.string().min(1), + description: z.string().optional(), + method, + path: relativePath.optional(), + toolDefinition: z.string().min(1), + paramLocations, + query: z.record(z.string(), z.string()).optional(), + responseKeys: z.array(z.string()).optional(), +}); + +const serviceDefinition = z + .object({ + name: z.string().min(1), + description: z.string().optional(), + // Legacy single-op fields (optional now that operations[] exists). + apiUrl: z.string().url().startsWith("https://").optional(), + method: method.optional(), + headers, + toolDefinition: z.string().min(1).optional(), + // Multi-op fields. + baseUrl: z.string().url().startsWith("https://").optional(), + operations: z.array(serviceOperation).optional(), + responseKeys: z.array(z.string()).optional(), + apiKeyUrl: z.string().url().startsWith("https://").optional(), + authMode: z.enum(["none", "oauth"]).optional(), + oauth: oauthConfig, + enabled: z.boolean(), + }) + // A service is valid if it's either legacy single-op (apiUrl+method+ + // toolDefinition) OR multi-op (baseUrl+operations). Reject a def that is + // neither, so a malformed connector is caught at parse time. + .refine( + (d) => + (d.operations && d.operations.length > 0 && d.baseUrl) || + (d.apiUrl && d.method && d.toolDefinition), + { message: "service must define either baseUrl+operations or apiUrl+method+toolDefinition" } + ); + +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; +} diff --git a/shared/chat/service-exec.test.ts b/shared/chat/service-exec.test.ts new file mode 100644 index 00000000..66a2a7e4 --- /dev/null +++ b/shared/chat/service-exec.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from "vitest"; +import { + namespaceServiceTool, + parseServiceToolName, + isServiceToolName, + parseToolDefinition, + serviceToOpenAI, + serviceOperationsToOpenAI, + resolveOperation, + normalizeOperations, + coerceArgs, + buildRequest, + buildOperationRequest, + filterResponse, + sampleArgsFromSchema, + type CustomServiceRuntimeConfig, +} from "./service-exec"; + +const cfg: CustomServiceRuntimeConfig = { + id: "svc1", + apiUrl: "https://api.example.com/search", + method: "GET", + headers: { Authorization: "Bearer literal" }, + toolDefinition: JSON.stringify({ + name: "search", + description: "Search things", + parameters: { type: "object", properties: { q: { type: "string" }, n: { type: "integer" } } }, + }), +}; +// toolDefinition is always set on the test cfg; alias so the widened optional +// type (string | undefined) doesn't require a non-null assertion at each use. +const toolDef = cfg.toolDefinition as string; + +describe("service-exec: name namespacing", () => { + it("round-trips a namespaced tool name", () => { + const n = namespaceServiceTool("svc1", "search"); + expect(n).toBe("svc__svc1__search"); + expect(parseServiceToolName(n)).toEqual({ serviceId: "svc1", toolName: "search" }); + expect(isServiceToolName(n)).toBe(true); + expect(isServiceToolName("create_note")).toBe(false); + }); + it("rejects malformed namespaced names", () => { + expect(parseServiceToolName("svc__")).toBeNull(); + expect(parseServiceToolName("nope")).toBeNull(); + }); +}); + +describe("service-exec: tool definition", () => { + it("parses bare and OpenAI-wrapped definitions", () => { + expect(parseToolDefinition(toolDef).name).toBe("search"); + const wrapped = JSON.stringify({ type: "function", function: { name: "x", description: "d", parameters: {} } }); + expect(parseToolDefinition(wrapped).name).toBe("x"); + }); + it("namespaces the OpenAI function name", () => { + expect(serviceToOpenAI(cfg).function.name).toBe("svc__svc1__search"); + }); +}); + +describe("service-exec: arg coercion", () => { + it("coerces stringified numbers to their declared type", () => { + const out = coerceArgs({ q: "hi", n: "10" }, parseToolDefinition(toolDef).parameters); + expect(out.n).toBe(10); + expect(out.q).toBe("hi"); + }); +}); + +describe("service-exec: request building", () => { + it("puts args on the query string for GET", () => { + const { url, init } = buildRequest(cfg, { q: "hi", n: 3 }, cfg.headers!, parseToolDefinition(toolDef).parameters); + expect(url).toContain("q=hi"); + expect(url).toContain("n=3"); + expect(init.method).toBe("GET"); + expect(init.body).toBeUndefined(); + }); + it("puts args in a JSON body for POST + adds content-type", () => { + const post = { ...cfg, method: "POST" as const }; + const { init } = buildRequest(post, { q: "hi" }, {}, undefined); + expect(init.body).toBe(JSON.stringify({ q: "hi" })); + expect((init.headers as Record)["Content-Type"]).toBe("application/json"); + }); +}); + +describe("service-exec: response filtering", () => { + it("keeps only wanted keys at any depth", () => { + const filtered = filterResponse({ web: { results: [{ title: "T", url: "U", junk: 1 }] } }, ["title", "url"]); + expect(filtered).toEqual({ web: { results: [{ title: "T", url: "U" }] } }); + }); + it("returns everything when no keys given", () => { + expect(filterResponse({ a: 1 }, [])).toEqual({ a: 1 }); + }); +}); + +describe("service-exec: sample args", () => { + it("populates required fields with typed placeholders", () => { + const params = { type: "object", properties: { q: { type: "string" }, n: { type: "integer" } }, required: ["q"] }; + const sample = sampleArgsFromSchema(params); + expect(sample.q).toBe("test"); + expect(sample.n).toBeUndefined(); // not required, no hint + }); +}); + +describe("service-exec: multi-operation", () => { + const github: CustomServiceRuntimeConfig = { + id: "github", + baseUrl: "https://api.github.com", + headers: { Authorization: "Bearer literal" }, + operations: [ + { + name: "get_issue", + method: "GET", + path: "/repos/{owner}/{repo}/issues/{number}", + toolDefinition: JSON.stringify({ name: "get_issue", description: "Get an issue", parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, number: { type: "integer" } } } }), + responseKeys: ["title", "state"], + }, + { + name: "create_issue", + method: "POST", + path: "/repos/{owner}/{repo}/issues", + toolDefinition: JSON.stringify({ name: "create_issue", description: "Create an issue", parameters: { type: "object", properties: { owner: { type: "string" }, repo: { type: "string" }, title: { type: "string" }, body: { type: "string" } } } }), + paramLocations: { title: "body", body: "body" }, + }, + ], + }; + + it("exposes one namespaced tool per operation", () => { + const tools = serviceOperationsToOpenAI(github); + expect(tools.map((t) => t.function.name)).toEqual(["svc__github__get_issue", "svc__github__create_issue"]); + }); + + it("resolves a namespaced tool name back to its operation", () => { + const op = resolveOperation(github, "svc__github__create_issue"); + expect(op?.toolName).toBe("create_issue"); + expect(op?.method).toBe("POST"); + expect(resolveOperation(github, "svc__github__nope")).toBeNull(); + expect(resolveOperation(github, "svc__other__get_issue")).toBeNull(); + }); + + it("fills path placeholders and puts the rest on the query (GET)", () => { + const op = resolveOperation(github, "svc__github__get_issue")!; + const params = parseToolDefinition(op.toolDefinition).parameters; + const { url, init } = buildOperationRequest(op, { owner: "cairn", repo: "app", number: 42 }, {}, params); + expect(url).toBe("https://api.github.com/repos/cairn/app/issues/42"); + expect(init.method).toBe("GET"); + expect(init.body).toBeUndefined(); + }); + + it("fills path + routes declared args to the JSON body (POST)", () => { + const op = resolveOperation(github, "svc__github__create_issue")!; + const params = parseToolDefinition(op.toolDefinition).parameters; + const { url, init } = buildOperationRequest(op, { owner: "cairn", repo: "app", title: "Bug", body: "It broke" }, {}, params); + expect(url).toBe("https://api.github.com/repos/cairn/app/issues"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ title: "Bug", body: "It broke" }); + }); + + it("URL-encodes path values", () => { + const op = resolveOperation(github, "svc__github__get_issue")!; + const { url } = buildOperationRequest(op, { owner: "a b", repo: "x/y", number: 1 }, {}, undefined); + expect(url).toContain("/repos/a%20b/x%2Fy/issues/1"); + }); + + it("normalizes a legacy single-op config into one operation", () => { + const ops = normalizeOperations(cfg); + expect(ops).toHaveLength(1); + expect(ops[0].toolName).toBe("search"); + expect(ops[0].url).toBe("https://api.example.com/search"); + // buildRequest (legacy) still works via the normalized op. + const { url } = buildRequest(cfg, { q: "hi" }, {}, undefined); + expect(url).toContain("q=hi"); + }); + + it("always sends static query params, model args override them", () => { + const weather: CustomServiceRuntimeConfig = { + id: "meteo", + baseUrl: "https://api.open-meteo.com/v1", + operations: [ + { + name: "get_weather", + method: "GET", + path: "/forecast", + query: { current: "temperature_2m,wind_speed_10m" }, + toolDefinition: JSON.stringify({ name: "get_weather", description: "w", parameters: { type: "object", properties: { latitude: { type: "number" }, longitude: { type: "number" } } } }), + }, + ], + }; + const op = resolveOperation(weather, "svc__meteo__get_weather")!; + // Model omits `current` (the bug): the static query still forces it. + const { url } = buildOperationRequest(op, { latitude: 52.52, longitude: 13.41 }, {}, undefined); + expect(url).toContain("current=temperature_2m%2Cwind_speed_10m"); + expect(url).toContain("latitude=52.52"); + // A model-supplied value of the same name overrides the static default. + const op2 = resolveOperation(weather, "svc__meteo__get_weather")!; + const { url: url2 } = buildOperationRequest(op2, { latitude: 1, longitude: 2, current: "rain" }, {}, undefined); + expect(url2).toContain("current=rain"); + }); +}); diff --git a/shared/chat/service-exec.ts b/shared/chat/service-exec.ts new file mode 100644 index 00000000..76517b10 --- /dev/null +++ b/shared/chat/service-exec.ts @@ -0,0 +1,508 @@ +/** + * Custom HTTP Service — PURE request-shaping + tool-definition helpers. + * + * This is the framework-free core of the custom-services executor, shared by the + * Electron desktop app and the Expo mobile app so both turn a user-/community- + * defined HTTP API into a single function-calling tool the SAME way. The IMPURE + * parts (network fetch, secret resolution, OAuth bearer injection) stay + * platform-specific and live next to each platform's runtime: + * - desktop: electron/lib/custom-services.ts (node fetch + keychain secrets) + * - mobile: (Track 2) expo/fetch + expo-secure-store + * + * Request shaping: + * - GET / DELETE → arguments become query-string params. + * - POST / PUT → arguments become a JSON request body. + * + * Tool names are namespaced `svc____` so they never collide + * with built-ins, MCP tools, or each other. + * + * No Node/Electron/RN imports → unit-testable in the root vitest suite. + */ + +const NS_PREFIX = "svc__"; +const NS_SEP = "__"; + +// ── Name namespacing (pure) ────────────────────────────────────────────────── + +export function namespaceServiceTool(serviceId: string, toolName: string): string { + return `${NS_PREFIX}${serviceId}${NS_SEP}${toolName}`; +} + +export function parseServiceToolName( + namespaced: string +): { serviceId: string; toolName: string } | null { + if (!namespaced.startsWith(NS_PREFIX)) return null; + const rest = namespaced.slice(NS_PREFIX.length); + const sep = rest.indexOf(NS_SEP); + if (sep <= 0 || sep >= rest.length - NS_SEP.length) return null; + return { serviceId: rest.slice(0, sep), toolName: rest.slice(sep + NS_SEP.length) }; +} + +export function isServiceToolName(name: string): boolean { + return name.startsWith(NS_PREFIX); +} + +// ── Tool definition (pure) ──────────────────────────────────────────────────── + +export interface OpenAIToolDef { + type: "function"; + function: { name: string; description: string; parameters: Record }; +} + +interface ParsedToolDefinition { + name: string; + description: string; + parameters: Record; +} + +/** + * Parse a service's stored `toolDefinition` JSON into name/description/params. + * Accepts either a bare `{ name, description, parameters }` object or the full + * OpenAI `{ type:"function", function:{…} }` wrapper. Throws on invalid JSON. + */ +export function parseToolDefinition(toolDefinition: string): ParsedToolDefinition { + const raw = JSON.parse(toolDefinition) as Record; + const fn = (raw.function ?? raw) as Record; + return { + name: typeof fn.name === "string" ? fn.name : "call", + description: typeof fn.description === "string" ? fn.description : "", + parameters: + fn.parameters && typeof fn.parameters === "object" + ? (fn.parameters as Record) + : { type: "object", properties: {} }, + }; +} + +// ── Service config (subset of CustomServiceConfig) ─────────────────────────── + +/** Where a tool argument is placed in the HTTP request. */ +export type ParamLocation = "path" | "query" | "body"; + +/** + * One operation of a (possibly multi-operation) HTTP service. A service groups + * several operations that share a base URL + headers/auth; each operation is + * exposed to the model as its own namespaced tool. + */ +export interface ServiceOperation { + /** Un-namespaced tool name, unique within the service. */ + name: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + /** + * Path appended to the service baseUrl, with `{placeholder}` segments filled + * from arguments (e.g. "/repos/{owner}/{repo}/issues/{number}"). May be "" when + * the baseUrl is already the full endpoint (legacy single-op services). + */ + path?: string; + /** Stringified OpenAI tool JSON (name/description/parameters) for THIS op. */ + toolDefinition: string; + /** + * Where each argument goes: "path" | "query" | "body". Args named by a + * `{placeholder}` in `path` are treated as "path" automatically. Any arg not + * listed defaults by method (query for GET/DELETE, body for POST/PUT) — so the + * legacy behaviour is the zero-config case. + */ + paramLocations?: Record; + /** + * Static query params ALWAYS sent for this operation (e.g. { format: "json", + * units: "metric" }, or Open-Meteo's `current` field list). These are supplied + * by the connector, not the model — so results don't depend on the model + * remembering to pass an optional arg (function-calling `default`s are NOT + * auto-applied). A model-supplied arg of the same name overrides the static one. + */ + query?: Record; + /** Response key allow-list for THIS op (falls back to the service default). */ + responseKeys?: string[]; +} + +export interface CustomServiceRuntimeConfig { + id: string; + /** + * Legacy single-op endpoint. For multi-op services use `baseUrl` + `operations` + * instead. When `operations` is present these are ignored (normalizeOperations + * prefers operations). + */ + apiUrl?: string; + method?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + toolDefinition?: string; + responseKeys?: string[]; + /** Base URL shared by all operations (multi-op). Operations' `path` is appended. */ + baseUrl?: string; + /** Multi-operation definition. When present, each becomes its own tool. */ + operations?: ServiceOperation[]; + /** "oauth" services inject a Bearer token resolved at call time (see resolveBearer). */ + authMode?: "none" | "oauth"; +} + +/** + * Resolves a fresh OAuth bearer for an "oauth" service at call time. Injected by + * the platform executor rather than imported directly so this module stays pure + * and framework-free. Returns null when the service isn't connected or a refresh + * fails, in which case no Authorization header is added. + */ +export type BearerResolver = (serviceId: string) => Promise; + +/** Convert a service config into a namespaced OpenAI function def. */ +export function serviceToOpenAI(cfg: CustomServiceRuntimeConfig): OpenAIToolDef { + const def = parseToolDefinition(cfg.toolDefinition ?? "{}"); + return { + type: "function", + function: { + name: namespaceServiceTool(cfg.id, def.name), + description: def.description, + parameters: def.parameters, + }, + }; +} + +// ── Multi-operation support (pure) ──────────────────────────────────────────── + +/** + * A normalized operation resolved against its owning service — the internal + * shape every code path uses so single-op and multi-op services share one flow. + */ +export interface ResolvedOperation { + toolName: string; // un-namespaced + method: "GET" | "POST" | "PUT" | "DELETE"; + url: string; // full URL (baseUrl + path), placeholders still present + toolDefinition: string; + paramLocations: Record; + /** Static query params always sent (model args of the same name override). */ + query?: Record; + responseKeys?: string[]; +} + +/** Join a base URL and a path fragment without doubling or dropping slashes. */ +function joinUrl(base: string, path?: string): string { + if (!path) return base; + return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`; +} + +/** Arg names referenced as {placeholders} in a path template. */ +function pathParams(path?: string): string[] { + if (!path) return []; + return [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]); +} + +/** + * Normalize a service config into a uniform list of operations. A multi-op + * service yields its `operations`; a legacy single-op service (apiUrl + method + + * toolDefinition) yields exactly one operation whose URL is the apiUrl. Path + * placeholders are folded into paramLocations as "path" automatically. + */ +export function normalizeOperations(cfg: CustomServiceRuntimeConfig): ResolvedOperation[] { + const ops: ServiceOperation[] = + cfg.operations && cfg.operations.length > 0 + ? cfg.operations + : [ + { + name: parseToolDefinition(cfg.toolDefinition ?? "{}").name, + method: cfg.method ?? "GET", + path: "", + toolDefinition: cfg.toolDefinition ?? "{}", + responseKeys: cfg.responseKeys, + }, + ]; + const base = cfg.baseUrl ?? cfg.apiUrl ?? ""; + return ops.map((op) => { + const locations: Record = { ...(op.paramLocations ?? {}) }; + for (const p of pathParams(op.path)) locations[p] = "path"; + return { + // Derive from the toolDefinition name (falling back to op.name) so + // ResolvedOperation.toolName is the SAME identifier the model calls — + // serviceOperationsToOpenAI + resolveOperation both key off the + // toolDefinition name, and op.name can legitimately differ from it. + toolName: parseToolDefinition(op.toolDefinition).name || op.name, + method: op.method, + url: joinUrl(base, op.path), + toolDefinition: op.toolDefinition, + paramLocations: locations, + query: op.query, + responseKeys: op.responseKeys ?? cfg.responseKeys, + }; + }); +} + +/** Every operation of a service as namespaced OpenAI function defs (one per op). */ +export function serviceOperationsToOpenAI(cfg: CustomServiceRuntimeConfig): OpenAIToolDef[] { + return normalizeOperations(cfg).map((op) => { + const def = parseToolDefinition(op.toolDefinition); + return { + type: "function" as const, + function: { + name: namespaceServiceTool(cfg.id, def.name), + description: def.description, + parameters: def.parameters, + }, + }; + }); +} + +/** + * Resolve a namespaced tool name (svc____) back to its operation within + * a service config. Returns null if the name isn't one of this service's ops. + */ +export function resolveOperation( + cfg: CustomServiceRuntimeConfig, + namespaced: string +): ResolvedOperation | null { + const parsed = parseServiceToolName(namespaced); + if (!parsed || parsed.serviceId !== cfg.id) return null; + for (const op of normalizeOperations(cfg)) { + if (op.toolName === parsed.toolName) return op; + } + return null; +} + +// ── Request shaping (pure) ──────────────────────────────────────────────────── + +/** + * Coerce model-supplied argument values to the JSON types declared in the + * tool's parameter schema. Some models (especially smaller ones) emit numbers + * and booleans as quoted strings (e.g. `"10"`, `"true"`) regardless of the + * schema. Strict APIs like Exa reject those with a 400. We walk the schema's + * top-level `properties` and convert each value to its declared type when it is + * safely coercible; anything not described by the schema, or not cleanly + * coercible, is passed through untouched. + */ +export function coerceArgs( + args: Record, + parameters: Record | undefined +): Record { + const props = + parameters && typeof parameters === "object" + ? (parameters.properties as Record | undefined) + : undefined; + if (!props || typeof props !== "object") return { ...args }; + + const out: Record = { ...args }; + for (const [key, value] of Object.entries(args ?? {})) { + const schema = props[key] as Record | undefined; + const declared = schema && typeof schema === "object" ? schema.type : undefined; + out[key] = coerceValue(value, declared); + } + return out; +} + +function coerceValue(value: unknown, declared: unknown): unknown { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + + if (declared === "number" || declared === "integer") { + if (trimmed === "" || !/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(trimmed)) return value; + const n = Number(trimmed); + if (!Number.isFinite(n)) return value; + return declared === "integer" ? Math.trunc(n) : n; + } + if (declared === "boolean") { + if (trimmed === "true") return true; + if (trimmed === "false") return false; + return value; + } + if (declared === "array" || declared === "object") { + // Models sometimes send JSON-encoded strings for structured params. + if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) { + try { + const parsed = JSON.parse(trimmed); + // Only accept the parse if its shape matches the declared type. + const shapeOk = declared === "array" ? Array.isArray(parsed) : isPlainObject(parsed); + return shapeOk ? parsed : value; + } catch { + return value; + } + } + } + return value; +} + +/** True for a non-null, non-array JSON object. */ +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Build the final URL + fetch init for a legacy single-op service. GET/DELETE + * put args on the query string; POST/PUT put them in a JSON body. Kept for + * backward-compatible callers; internally delegates to buildOperationRequest via + * a normalized single operation. Header secret refs must already be resolved. + */ +export function buildRequest( + cfg: CustomServiceRuntimeConfig, + args: Record, + resolvedHeaders: Record, + parameters?: Record +): { url: string; init: RequestInit } { + const [op] = normalizeOperations(cfg); + return buildOperationRequest(op, args, resolvedHeaders, parameters); +} + +/** + * Build the URL + fetch init for a resolved operation. Arguments are placed by + * `op.paramLocations`: + * - "path" → fills a {placeholder} in the URL (URL-encoded). + * - "query" → query-string param. + * - "body" → JSON body field. + * Unlisted args default by method: query for GET/DELETE, body for POST/PUT (the + * legacy zero-config behaviour). `parameters` is the op's JSON-schema parameters, + * used to coerce stringified numbers/booleans the model may have emitted. + */ +export function buildOperationRequest( + op: ResolvedOperation, + args: Record, + resolvedHeaders: Record, + parameters?: Record +): { url: string; init: RequestInit } { + const headers: Record = { ...resolvedHeaders }; + const coerced = coerceArgs(args ?? {}, parameters); + const defaultLoc: ParamLocation = op.method === "GET" || op.method === "DELETE" ? "query" : "body"; + + // 1) Fill path placeholders, then build the URL. + let filledPath = op.url; + const body: Record = {}; + const queryEntries: [string, unknown][] = []; + + for (const [k, v] of Object.entries(coerced)) { + if (v === undefined || v === null) continue; + const loc = op.paramLocations[k] ?? defaultLoc; + if (loc === "path") { + // Replace {k} in the URL; encode the value. If the placeholder isn't + // present (mis-declared), fall through to query so the value isn't lost. + const token = `{${k}}`; + if (filledPath.includes(token)) { + // Global replace without String.replaceAll (shared targets ES2020). + filledPath = filledPath.split(token).join(encodeURIComponent(String(v))); + } else { + queryEntries.push([k, v]); + } + } else if (loc === "query") { + queryEntries.push([k, v]); + } else { + body[k] = v; + } + } + + const url = new URL(filledPath); + // Static query params first (connector-supplied), then model args — so a + // model-provided value of the same name overrides the static default. + for (const [k, v] of Object.entries(op.query ?? {})) { + url.searchParams.set(k, v); + } + for (const [k, v] of queryEntries) { + url.searchParams.set(k, typeof v === "object" ? JSON.stringify(v) : String(v)); + } + + let bodyStr: string | undefined; + if (op.method === "POST" || op.method === "PUT") { + // Always send a body for write methods (even if empty) to match prior + // behaviour where POST/PUT stringified all args. + bodyStr = JSON.stringify(body); + if (!hasHeader(headers, "content-type")) headers["Content-Type"] = "application/json"; + } else if (Object.keys(body).length > 0) { + // A GET/DELETE op that explicitly routed args to "body" (unusual but allowed). + bodyStr = JSON.stringify(body); + if (!hasHeader(headers, "content-type")) headers["Content-Type"] = "application/json"; + } + + return { url: url.toString(), init: { method: op.method, headers, body: bodyStr } }; +} + +function hasHeader(headers: Record, name: string): boolean { + const lower = name.toLowerCase(); + return Object.keys(headers).some((k) => k.toLowerCase() === lower); +} + +// ── Response filtering (pure) ───────────────────────────────────────────────── + +/** + * Deep-filter a response value to only the keys in `keys`. Matching is by key + * name at any depth: arrays are mapped, objects keep matching keys *and* recurse + * into non-matching object/array values so a wanted key nested deeper still + * surfaces. When `keys` is empty/undefined the value is returned unchanged. + */ +export function filterResponse(value: unknown, keys?: string[]): unknown { + if (!keys || keys.length === 0) return value; + const wanted = new Set(keys); + return pick(value, wanted); +} + +function pick(value: unknown, wanted: Set): unknown { + if (Array.isArray(value)) { + return value.map((item) => pick(item, wanted)); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (wanted.has(k)) { + out[k] = v; // keep the whole subtree of an explicitly wanted key + } else if (v && typeof v === "object") { + const nested = pick(v, wanted); + if (!isEmptyContainer(nested)) out[k] = nested; + } + } + return out; + } + return value; +} + +function isEmptyContainer(v: unknown): boolean { + if (Array.isArray(v)) return v.length === 0 || v.every(isEmptyContainer); + if (v && typeof v === "object") return Object.keys(v).length === 0; + return false; +} + +// ── Sample args (pure) — used by the Settings "Test connection" dry-run ─────── + +/** + * Build a minimal-but-valid sample argument object from a JSON-schema parameter + * definition. Populates every `required` property (and any property that + * declares an `example`/`default`) with a representative value of the correct + * type. Best-effort: unknown shapes fall back to a sensible primitive. + */ +export function sampleArgsFromSchema(parameters: Record | undefined): Record { + const props = + parameters && typeof parameters === "object" + ? (parameters.properties as Record | undefined) + : undefined; + if (!props || typeof props !== "object") return {}; + + const required = Array.isArray((parameters as Record).required) + ? ((parameters as Record).required as string[]) + : []; + const requiredSet = new Set(required); + + const out: Record = {}; + for (const [key, rawSchema] of Object.entries(props)) { + const schema = (rawSchema && typeof rawSchema === "object" ? rawSchema : {}) as Record; + const hasHint = schema.example !== undefined || schema.default !== undefined; + if (!requiredSet.has(key) && !hasHint) continue; + out[key] = sampleValue(key, schema); + } + return out; +} + +function sampleValue(key: string, schema: Record): unknown { + if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0]; + if (schema.example !== undefined) return schema.example; + if (schema.default !== undefined) return schema.default; + + switch (schema.type) { + case "number": + case "integer": + return 1; + case "boolean": + return true; + case "array": { + const items = (schema.items && typeof schema.items === "object" ? schema.items : {}) as Record; + return [sampleValue(key, items)]; + } + case "object": { + const nested = schema.properties && typeof schema.properties === "object" + ? sampleArgsFromSchema(schema) + : {}; + return nested; + } + case "string": + default: + return "test"; + } +} diff --git a/src/components/settings/ToolsSettings.tsx b/src/components/settings/ToolsSettings.tsx index 80b696ba..d8a779be 100644 --- a/src/components/settings/ToolsSettings.tsx +++ b/src/components/settings/ToolsSettings.tsx @@ -269,7 +269,9 @@ export function ToolsSettings() { : } name={svc.name} - subtitle={`${svc.method} ${svc.apiUrl}`} + subtitle={svc.operations && svc.operations.length > 0 + ? `${svc.baseUrl ?? ""} · ${svc.operations.length} tools` + : `${svc.method ?? ""} ${svc.apiUrl ?? ""}`.trim()} enabled={svc.enabled} onToggle={(v) => { void saveCustomService({ ...svc, enabled: v }).catch((e) => setSaveError(e instanceof Error ? e.message : "Failed to update service.")); }} onEdit={() => setEditingSvc(svc.id)} diff --git a/src/components/settings/tools/BrowseCommunityModal.tsx b/src/components/settings/tools/BrowseCommunityModal.tsx index 5ec05f46..a8e6f3d8 100644 --- a/src/components/settings/tools/BrowseCommunityModal.tsx +++ b/src/components/settings/tools/BrowseCommunityModal.tsx @@ -54,7 +54,10 @@ function isOAuth(e: CardEntry): boolean { : e.service!.definition.authMode === "oauth"; } function baseUrlOf(e: CardEntry): string { - return e.kind === "mcp" ? e.mcp!.definition.baseUrl : e.service!.definition.apiUrl; + if (e.kind === "mcp") return e.mcp!.definition.baseUrl; + // Services: multi-op uses baseUrl; legacy single-op uses apiUrl. + const d = e.service!.definition; + return d.baseUrl ?? d.apiUrl ?? ""; } /** diff --git a/src/store/slices/tools.ts b/src/store/slices/tools.ts index 532fbdb7..ed437a9b 100644 --- a/src/store/slices/tools.ts +++ b/src/store/slices/tools.ts @@ -274,6 +274,8 @@ export const createToolsSlice: StateCreator = (s method: entry.definition.method, headers, toolDefinition: entry.definition.toolDefinition, + baseUrl: entry.definition.baseUrl, + operations: entry.definition.operations, responseKeys: entry.definition.responseKeys, apiKeyUrl: entry.definition.apiKeyUrl, authMode: entry.definition.authMode, diff --git a/src/types/index.ts b/src/types/index.ts index 1f61f074..71b2e880 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -167,17 +167,34 @@ export interface McpServerConfig { } /** Custom HTTP API exposed to the AI as a single function-calling tool. */ +/** One operation of a multi-operation HTTP service (mirrors the registry shape). */ +export interface ServiceOperationConfig { + name: string; + description?: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + path?: string; + toolDefinition: string; + paramLocations?: Record; + query?: Record; + responseKeys?: string[]; +} + export interface CustomServiceConfig { id: ID; workspaceId: ID; name: string; description?: string; - apiUrl: string; - method: "GET" | "POST" | "PUT" | "DELETE"; - /** Header values may be literal or a "secret://" ref. */ + /** Legacy single-op endpoint. For multi-op services use baseUrl + operations. */ + apiUrl?: string; + method?: "GET" | "POST" | "PUT" | "DELETE"; + /** Header values may be literal or a "secret://" ref. Shared across operations. */ headers?: Record; - /** Stringified OpenAI tool JSON (name/description/parameters). */ - toolDefinition: string; + /** Legacy single-op stringified OpenAI tool JSON (name/description/parameters). */ + toolDefinition?: string; + /** Base URL shared by all operations (multi-op); each operation's path appends. */ + baseUrl?: string; + /** Multi-operation definition — each becomes its own namespaced tool. */ + operations?: ServiceOperationConfig[]; /** Keys to keep from the API response (token optimisation). */ responseKeys?: string[]; /** Where the user can obtain an API key. */ @@ -251,10 +268,16 @@ export interface RegistryMcpDefinition { export interface RegistryServiceDefinition { name: string; description?: string; - apiUrl: string; - method: "GET" | "POST" | "PUT" | "DELETE"; + /** Legacy single-op endpoint. Multi-op services use baseUrl + operations. */ + apiUrl?: string; + method?: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record; - toolDefinition: string; + /** Legacy single-op tool. */ + toolDefinition?: string; + /** Base URL shared by all operations (multi-op). */ + baseUrl?: string; + /** Multi-operation definition — each becomes its own namespaced tool. */ + operations?: ServiceOperationConfig[]; responseKeys?: string[]; apiKeyUrl?: string; /** Mirror of CustomServiceConfig auth fields so the registry can ship an OAuth preset. */