From a8f3bfa3d49e1426df94fd481c73e94c95433965 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:27:33 +0000 Subject: [PATCH 01/16] feat(server): add OAuth tables migration and token expiry support Adds oauth_clients + oauth_auth_codes tables (RFC 7591 dynamic client registration + PKCE authorization codes) and a nullable tokens.expires_at column (NULL = never expires, preserving existing token behavior). Wires expiry enforcement into authenticateUser's API-token lookup and expired-row cleanup into the janitor. Also adds the mcpAuth middleware wrapper that attaches a WWW-Authenticate header to 401s so MCP clients can discover the OAuth metadata endpoint (mounted on /mcp in a later commit). Part of plan 003 (bundled MCP server + OAuth 2.1). --- .../migrations/0027_add_oauth_tables.sql | 29 ++++++++++++++++ apps/server/src/foundation/auth.ts | 33 +++++++++++++++++-- apps/server/src/janitor.ts | 12 +++++-- apps/server/src/types.d.ts | 21 ++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 apps/server/migrations/0027_add_oauth_tables.sql diff --git a/apps/server/migrations/0027_add_oauth_tables.sql b/apps/server/migrations/0027_add_oauth_tables.sql new file mode 100644 index 00000000..83a95d99 --- /dev/null +++ b/apps/server/migrations/0027_add_oauth_tables.sql @@ -0,0 +1,29 @@ +-- Migration number: 0027 2026-07-08 +-- OAuth 2.1 authorization server for the bundled MCP endpoint (/mcp). Adds +-- dynamic client registration (RFC 7591) storage and short-lived, single-use +-- authorization codes for the PKCE authorization_code grant. + +-- OAuth 2.1 dynamic client registrations (RFC 7591) for MCP clients. +CREATE TABLE oauth_clients ( + id TEXT PRIMARY KEY, -- client_id (uuid) + client_name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, -- JSON array of exact-match URIs + created_at INTEGER NOT NULL +); + +-- Short-lived authorization codes (10 min), single use. +CREATE TABLE oauth_auth_codes ( + id TEXT PRIMARY KEY, -- uuid + code_hash TEXT NOT NULL UNIQUE, -- HMAC of the code, same scheme as tokens + client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, -- PKCE S256 challenge + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX idx_oauth_auth_codes_expires ON oauth_auth_codes(expires_at); + +-- API tokens gain an optional expiry. NULL = never expires (all existing +-- tokens and dashboard-created tokens). OAuth-minted MCP tokens set it. +ALTER TABLE tokens ADD COLUMN expires_at INTEGER; diff --git a/apps/server/src/foundation/auth.ts b/apps/server/src/foundation/auth.ts index f95fc332..f24170b2 100644 --- a/apps/server/src/foundation/auth.ts +++ b/apps/server/src/foundation/auth.ts @@ -120,6 +120,30 @@ export async function cliAuthUser(c: AppContext, next: Next) { } } +/** + * Wraps authenticateUser for the /mcp endpoint: on a 401, adds a + * `WWW-Authenticate` header pointing MCP clients at the OAuth + * protected-resource metadata document so they start OAuth discovery + * automatically instead of just failing. Session-cookie auth also passing + * this check through to /mcp is harmless (same-origin browser only); no + * attempt is made to exclude it. + */ +export async function mcpAuth(c: AppContext, next: Next) { + const response = await authenticateUser(c, next); + + if (response instanceof Response && response.status === 401) { + const origin = new URL(c.req.url).origin; + const headers = new Headers(response.headers); + headers.set( + "WWW-Authenticate", + `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, + ); + return new Response(response.body, { status: 401, headers }); + } + + return response; +} + export async function authenticateUser(c: AppContext, next: Next) { const token = getToken(c); @@ -233,8 +257,13 @@ export async function authenticateUser(c: AppContext, next: Next) { on: "t.user_id = u.id", }, where: { - conditions: ["t.token_hash = ?1"], - params: [apiTokenHash], + // NULL expires_at = never expires (pre-existing + dashboard-created + // tokens); OAuth-minted tokens (see src/oauth/) set a real expiry. + conditions: [ + "t.token_hash = ?1", + "(t.expires_at IS NULL OR t.expires_at > ?2)", + ], + params: [apiTokenHash, Date.now()], }, }) .execute(); diff --git a/apps/server/src/janitor.ts b/apps/server/src/janitor.ts index 696ec182..ab6ac6ec 100644 --- a/apps/server/src/janitor.ts +++ b/apps/server/src/janitor.ts @@ -7,8 +7,10 @@ export const JANITOR_INTERVAL_MS = 60 * 60 * 1000; // hourly /** * In-process housekeeping. Runs at boot and then hourly: drops expired - * sessions, expired CLI auth codes, expired CLI tokens, device logs past - * retention, and usage buckets past the longest metrics window. + * sessions, expired CLI auth codes, expired CLI tokens, expired OAuth + * authorization codes, expired API tokens (OAuth-minted tokens carry a real + * expires_at; NULL-expiry tokens never match and are left alone), device logs + * past retention, and usage buckets past the longest metrics window. */ export function runJanitor(qb: BunSqliteQB): void { const now = Date.now(); @@ -16,6 +18,12 @@ export function runJanitor(qb: BunSqliteQB): void { qb.db.query("DELETE FROM user_sessions WHERE expires_at < ?1").run(now); qb.db.query("DELETE FROM cli_auth_codes WHERE expires_at < ?1").run(now); qb.db.query("DELETE FROM cli_tokens WHERE expires_at < ?1").run(now); + qb.db.query("DELETE FROM oauth_auth_codes WHERE expires_at < ?1").run(now); + qb.db + .query( + "DELETE FROM tokens WHERE expires_at IS NOT NULL AND expires_at < ?1", + ) + .run(now); qb.db .query("DELETE FROM device_logs WHERE created_at < ?1") .run(now - LOG_RETENTION_MS); diff --git a/apps/server/src/types.d.ts b/apps/server/src/types.d.ts index b2cfd192..35ce1072 100644 --- a/apps/server/src/types.d.ts +++ b/apps/server/src/types.d.ts @@ -93,6 +93,27 @@ export type tableTokens = { managed?: number; token_hash?: string; last_four?: string; + /** NULL = never expires (all pre-existing and dashboard-created tokens). */ + expires_at?: number | null; +}; + +export type tableOauthClients = { + id: string; + client_name: string; + /** JSON-encoded array of exact-match redirect URIs. */ + redirect_uris: string; + created_at: number; +}; + +export type tableOauthAuthCodes = { + id: string; + code_hash: string; + client_id: string; + user_id: string; + redirect_uri: string; + code_challenge: string; + created_at: number; + expires_at: number; }; export type tableProjectEnvVars = { From 57ca51b81e3c6a13a7a9c34ebd42a25d71e81622 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:28:50 +0000 Subject: [PATCH 02/16] feat(server): add @modelcontextprotocol/sdk and @hono/mcp dependencies Both will back the stateless Streamable-HTTP /mcp endpoint added in a follow-up commit. Versions align with the lockfile's existing MCP SDK version (1.29.0, already present transitively via packages/mcp) and the latest @hono/mcp release (0.3.0). Part of plan 003 (bundled MCP server + OAuth 2.1). --- apps/server/package.json | 2 ++ pnpm-lock.yaml | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/apps/server/package.json b/apps/server/package.json index ac12ff46..5eb46fd1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -19,6 +19,8 @@ }, "dependencies": { "@devicesdk/core": "workspace:*", + "@hono/mcp": "^0.3.0", + "@modelcontextprotocol/sdk": "^1.29.0", "chanfana": "3.3.0", "hono": "^4.12.23", "workers-qb": "^1.11.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 329acbb1..2bc9efbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,12 @@ importers: '@devicesdk/core': specifier: workspace:* version: link:../../packages/core + '@hono/mcp': + specifier: ^0.3.0 + version: 0.3.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(hono-rate-limiter@0.5.3(hono@4.12.23))(hono@4.12.23)(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) chanfana: specifier: 3.3.0 version: 3.3.0(patch_hash=mwdcsktalhf2qc335xrgn4bizi) @@ -1259,6 +1265,14 @@ packages: '@floating-ui/vue@1.1.10': resolution: {integrity: sha512-vdf8f6rHnFPPLRsmL4p12wYl+Ux4mOJOkjzKEMYVnwdf7UFdvBtHlLvQyx8iKG5vhPRbDRgZxdtpmyigDPjzYg==} + '@hono/mcp@0.3.0': + resolution: {integrity: sha512-UhSWFbZwRxHBdptOn2r1HyB8Vt6gU+BL3AbTis2t8TBW69ZhG4soJQ09yEL/t/ymxszJnWp5Rq6tOXXOjuK1qg==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.1 + hono: '*' + hono-rate-limiter: ^0.5.3 + zod: ^3.25.0 || ^4.0.0 + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -3182,6 +3196,15 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono-rate-limiter@0.5.3: + resolution: {integrity: sha512-M0DxbVMpPELEzLi0AJg1XyBHLGJXz7GySjsPoK+gc5YeeBsdGDGe+2RvVuCAv8ydINiwlbxqYMNxUEyYfRji/A==} + peerDependencies: + hono: ^4.10.8 + unstorage: ^1.17.3 + peerDependenciesMeta: + unstorage: + optional: true + hono@4.12.23: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} engines: {node: '>=16.9.0'} @@ -5902,6 +5925,14 @@ snapshots: - '@vue/composition-api' - vue + '@hono/mcp@0.3.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(hono-rate-limiter@0.5.3(hono@4.12.23))(hono@4.12.23)(zod@4.3.6)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) + hono: 4.12.23 + hono-rate-limiter: 0.5.3(hono@4.12.23) + pkce-challenge: 5.0.1 + zod: 4.3.6 + '@hono/node-server@1.19.14(hono@4.12.23)': dependencies: hono: 4.12.23 @@ -7969,6 +8000,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hono-rate-limiter@0.5.3(hono@4.12.23): + dependencies: + hono: 4.12.23 + hono@4.12.23: {} hookable@5.5.3: {} From 2590bd563bb5c3d9498033632447deec62293054 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:43:53 +0000 Subject: [PATCH 03/16] feat(server): stateless MCP endpoint at /mcp with 15 tools + docs search Bundles a stateless Streamable-HTTP MCP server (@hono/mcp + @modelcontextprotocol/sdk) at POST /mcp: a fresh McpServer/transport is built per request (no Mcp-Session-Id, no server-to-client push), and every tool re-enters the REST API in-process via Hono's app.request(path, init, c.env) forwarding the caller's own Authorization/Cookie header - one source of truth for validation, limits, and response shapes. GET/DELETE /mcp return 405 (this server never needs to resume a stream or terminate a session). 15 devicesdk_* tools cover whoami, project/device listing, status, metrics, env vars, script upload/versions/deploy, hardware commands, and a local docs search. devicesdk_docs_search queries a SQLite FTS5 index built at server-build time from docs/public/**/*.md (apps/server/scripts/build-docs-index.ts, wired into the "build" script) - offline, version-pinned search with no runtime network call. The path-to-URL mapping mirrors apps/website/scripts/build-content.ts's routePathFromRel/resolveRoutePath. devicesdk_device_logs wraps GET .../logs faithfully even though that REST endpoint is currently deprecated (always 410, pointing at the watch WebSocket) - the tool surfaces that response as-is rather than special-casing it, consistent with the loopback design wrapping the REST API unchanged. Verified end to end: tools/list returns all 15 tools; tools/call exercised for every tool including error paths (503 device not connected, 404 unknown version, cross-user project isolation) against a live server instance. Part of plan 003 (bundled MCP server + OAuth 2.1). --- apps/server/package.json | 2 +- apps/server/scripts/build-docs-index.ts | 312 ++++++++++++++++ apps/server/src/config.ts | 10 + apps/server/src/index.ts | 17 +- apps/server/src/mcp/docsSearch.ts | 112 ++++++ apps/server/src/mcp/mcpServer.ts | 15 + apps/server/src/mcp/route.ts | 106 ++++++ apps/server/src/mcp/tools.ts | 453 ++++++++++++++++++++++++ 8 files changed, 1025 insertions(+), 2 deletions(-) create mode 100644 apps/server/scripts/build-docs-index.ts create mode 100644 apps/server/src/mcp/docsSearch.ts create mode 100644 apps/server/src/mcp/mcpServer.ts create mode 100644 apps/server/src/mcp/route.ts create mode 100644 apps/server/src/mcp/tools.ts diff --git a/apps/server/package.json b/apps/server/package.json index 5eb46fd1..fcd75c7c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,7 +8,7 @@ "dev": "bun run --watch src/server.ts", "start": "bun run src/server.ts", "openapi": "bun run scripts/generate-openapi.ts", - "build": "pnpm run openapi", + "build": "pnpm run openapi && bun run scripts/build-docs-index.ts", "check-types": "tsc --noEmit", "test": "bun test src", "test:e2e": "bun test src tests", diff --git a/apps/server/scripts/build-docs-index.ts b/apps/server/scripts/build-docs-index.ts new file mode 100644 index 00000000..bcd3aae3 --- /dev/null +++ b/apps/server/scripts/build-docs-index.ts @@ -0,0 +1,312 @@ +#!/usr/bin/env bun +/** + * Builds a SQLite FTS5 full-text index of docs/public/**\/*.md at build time. + * The index is shipped inside the Docker image and queried locally (BM25, no + * network call) by devicesdk_docs_search - so docs search works offline and + * always matches the docs the running server version shipped with, even if + * the live devicesdk.com site has since moved on. + * + * Path-to-URL mapping mirrors apps/website/scripts/build-content.ts's + * routePathFromRel/resolveRoutePath (docs are mounted at the "/docs" prefix + * there). Keep the two in sync if either changes - a mismatch here means the + * index ships URLs that don't match the live site. + * + * Frontmatter parsing is a minimal hand-rolled YAML subset (no gray-matter/js-yaml + * dependency in apps/server - Bun-only deps stay minimal here): simple + * `key: value` scalars, optionally single/double-quoted, plus `>-` folded + * multi-line scalars (the only block-scalar style used in docs/public today) + * and a nested `build:` map for the `render: never` skip flag. This covers + * every frontmatter shape actually used under docs/public as of this writing; + * anything fancier is intentionally ignored rather than mis-parsed. + */ +import { Database } from "bun:sqlite"; +import { + type Dirent, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, +} from "node:fs"; +import { dirname, extname, join, parse, resolve } from "node:path"; + +/** apps/server/scripts/ -> repo root -> docs/public (three levels up). */ +function defaultInputDir(): string { + return new URL("../../../docs/public", import.meta.url).pathname; +} + +/** apps/server/scripts/ -> apps/server/dist/docs-index.sqlite (one level up). */ +function defaultOutputPath(): string { + return new URL("../dist/docs-index.sqlite", import.meta.url).pathname; +} + +const inputDir = process.argv[2] + ? resolve(process.cwd(), process.argv[2]) + : defaultInputDir(); +const outputPath = process.argv[3] + ? resolve(process.cwd(), process.argv[3]) + : defaultOutputPath(); + +function walkMarkdownFiles(dir: string): string[] { + const results: string[] = []; + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (err) { + console.error( + `Cannot read docs directory ${dir}: ${(err as Error).message}`, + ); + return results; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkMarkdownFiles(full)); + } else if (entry.isFile() && extname(entry.name) === ".md") { + results.push(full); + } + } + return results; +} + +interface Frontmatter { + title?: string; + description?: string; + url?: string; + slug?: string; + draft?: boolean; + buildRenderNever?: boolean; +} + +function unquote(value: string): string { + const trimmed = value.trim(); + if ( + trimmed.length >= 2 && + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +/** + * Splits `raw` into { frontmatter, body }. Returns body === raw unchanged if + * there is no well-formed leading `---` frontmatter block. + */ +function splitFrontmatter(raw: string): { fm: Frontmatter; body: string } { + const fm: Frontmatter = {}; + if (!raw.startsWith("---")) return { fm, body: raw }; + + const lines = raw.split("\n"); + if (lines[0].trim() !== "---") return { fm, body: raw }; + + let end = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === "---") { + end = i; + break; + } + } + if (end === -1) return { fm, body: raw }; + + const fmLines = lines.slice(1, end); + const body = lines.slice(end + 1).join("\n"); + + let i = 0; + let inBuildBlock = false; + while (i < fmLines.length) { + const line = fmLines[i]; + const topLevelMatch = /^([A-Za-z_][A-Za-z0-9_]*):(.*)$/.exec(line); + + if (!topLevelMatch) { + // Indented line: either a `build:` submap entry or a list item under a + // key we don't care about (e.g. `aliases:`) - only act on it while + // inside a recognized submap. + if (inBuildBlock) { + const nested = /^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line); + if ( + nested && + nested[1] === "render" && + unquote(nested[2]) === "never" + ) { + fm.buildRenderNever = true; + } + } + i++; + continue; + } + + inBuildBlock = false; + const key = topLevelMatch[1]; + const rest = topLevelMatch[2].trim(); + + if (key === "build" && rest === "") { + inBuildBlock = true; + i++; + continue; + } + + if ( + (key === "description" || key === "title") && + (rest === ">-" || rest === ">") + ) { + // Folded block scalar: consecutive indented lines join with spaces, + // trailing blank line (chomped by `-`) is dropped. + const parts: string[] = []; + let j = i + 1; + while (j < fmLines.length && /^\s+\S/.test(fmLines[j])) { + parts.push(fmLines[j].trim()); + j++; + } + const value = parts.join(" "); + if (key === "description") fm.description = value; + else fm.title = value; + i = j; + continue; + } + + if (key === "draft") { + fm.draft = unquote(rest) === "true"; + } else if (key === "title") { + fm.title = unquote(rest); + } else if (key === "description") { + fm.description = unquote(rest); + } else if (key === "url") { + fm.url = unquote(rest); + } else if (key === "slug") { + fm.slug = unquote(rest); + } + i++; + } + + return { fm, body }; +} + +/** Mirrors build-content.ts's shouldSkipPage (draft + build.render==="never"). */ +function shouldSkipPage(fm: Frontmatter): boolean { + return fm.draft === true || fm.buildRenderNever === true; +} + +/** Mirrors build-content.ts's routePathFromRel for the "/docs" prefix. */ +function routePathFromRel(relPath: string, prefix: string): string { + const parsed = parse(relPath); + const dir = parsed.dir ? parsed.dir.replace(/\\/g, "/") : ""; + const name = parsed.name; + if (name === "_index") { + if (!dir) return prefix ? `${prefix.replace(/\/+$/, "")}/` : "/"; + return `${prefix}/${dir}/`.replace(/\/+/g, "/"); + } + if (!dir) return `${prefix}/${name}/`.replace(/\/+/g, "/"); + return `${prefix}/${dir}/${name}/`.replace(/\/+/g, "/"); +} + +function normalizeRoutePath(value: string): string { + let p = value.trim(); + if (!p.startsWith("/")) p = `/${p}`; + if (!p.endsWith("/")) p = `${p}/`; + return p.replace(/\/+/g, "/"); +} + +/** Mirrors build-content.ts's resolveRoutePath: url > slug > filename. */ +function resolveRoutePath( + relPath: string, + prefix: string, + fm: Frontmatter, +): string { + if (fm.url) return normalizeRoutePath(fm.url); + if (fm.slug) { + const parsed = parse(relPath); + const dir = parsed.dir ? parsed.dir.replace(/\\/g, "/") : ""; + const safeSlug = fm.slug.replace(/^\/+|\/+$/g, "").replace(/\/+/g, "-"); + const sluggedRel = dir ? `${dir}/${safeSlug}.md` : `${safeSlug}.md`; + return routePathFromRel(sluggedRel, prefix); + } + return routePathFromRel(relPath, prefix); +} + +/** + * Strips markdown syntax down to plain text for FTS indexing: fenced code + * markers (keeping the code itself), inline code backticks, links/images + * (keeping the visible text), heading hashes, emphasis markers, blockquote + * markers, and horizontal rules. + */ +function stripMarkdown(content: string): string { + return content + .replace(/^```[^\n]*\n/gm, "") + .replace(/^```$/gm, "") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/^>\s?/gm, "") + .replace(/^(?:---+|\*\*\*+)$/gm, "") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function main(): void { + if (!existsSync(inputDir)) { + console.error(`Docs directory not found: ${inputDir}`); + process.exit(1); + } + + if (existsSync(outputPath)) rmSync(outputPath); + mkdirSync(dirname(outputPath), { recursive: true }); + + const db = new Database(outputPath, { create: true }); + db.exec( + "CREATE VIRTUAL TABLE docs_fts USING fts5(path UNINDEXED, title, description, content, tokenize='porter unicode61')", + ); + db.exec( + "CREATE TABLE meta (built_at INTEGER NOT NULL, doc_count INTEGER NOT NULL)", + ); + + const insert = db.prepare( + "INSERT INTO docs_fts (path, title, description, content) VALUES (?1, ?2, ?3, ?4)", + ); + + const files = walkMarkdownFiles(inputDir).sort(); + let indexed = 0; + + const insertAll = db.transaction(() => { + for (const abs of files) { + const relPath = abs.slice(inputDir.length).replace(/^[/\\]/, ""); + const raw = readFileSync(abs, "utf-8"); + const { fm, body } = splitFrontmatter(raw); + if (shouldSkipPage(fm)) continue; + + const title = fm.title || parse(relPath).name; + const description = fm.description ?? ""; + const path = resolveRoutePath(relPath, "/docs", fm); + const content = stripMarkdown(body); + + insert.run(path, title, description, content); + indexed++; + } + }); + insertAll(); + + db.prepare("INSERT INTO meta (built_at, doc_count) VALUES (?1, ?2)").run( + Date.now(), + indexed, + ); + db.close(); + + console.log(`indexed ${indexed} pages -> ${outputPath}`); + if (indexed === 0) { + console.error( + "No pages were indexed - refusing to ship an empty docs index.", + ); + process.exit(1); + } +} + +main(); + +export { resolveRoutePath, shouldSkipPage, splitFrontmatter, stripMarkdown }; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 16f3c1b2..b8caad82 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -33,6 +33,13 @@ export interface ServerConfig { scriptsDir: string; firmwaresDir: string; migrationsDir: string; + /** + * Path to the build-time SQLite FTS5 docs index queried by + * devicesdk_docs_search (see scripts/build-docs-index.ts). Missing file is + * tolerated (dev checkouts that haven't run the server build) - the tool + * reports it as a hint to run the build rather than throwing. + */ + docsIndexPath: string; /** * Server-side secret used for HMAC-SHA-256 hashing of API/CLI tokens. * Prefer the API_TOKEN_SECRET env var; when omitted a random secret is @@ -91,6 +98,9 @@ export function loadConfig( // import.meta.url no longer sits next to the migrations directory. migrationsDir: env.MIGRATIONS_DIR || new URL("../migrations", import.meta.url).pathname, + docsIndexPath: + env.DOCS_INDEX_PATH || + new URL("../dist/docs-index.sqlite", import.meta.url).pathname, apiTokenSecret: loadOrCreateApiTokenSecret(env, dataDir), }; } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c739d996..14a0c870 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -25,9 +25,15 @@ import { projectsRouter } from "./endpoints/projects/router"; import { batchScriptsRouter, scriptsRouter } from "./endpoints/scripts/router"; import { tokensRouter } from "./endpoints/tokens/router"; import { userRouter } from "./endpoints/user/router"; -import { authenticateUser, cliAuthUser, handleLogout } from "./foundation/auth"; +import { + authenticateUser, + cliAuthUser, + handleLogout, + mcpAuth, +} from "./foundation/auth"; import { logger } from "./foundation/logger"; import { rateLimitMiddleware } from "./foundation/rateLimit"; +import { createMcpPostRoute, mcpMethodNotAllowed } from "./mcp/route"; import { serveSpa } from "./spa"; import type { Env, Variables } from "./types"; @@ -134,6 +140,15 @@ app.route("/v1/cli/auth", cliAuthRouterPreAuth); app.get("/cli/auth", cliAuthUser, getApprovalPage); app.post("/cli/auth", cliAuthUser, handleApproval); +// Bundled MCP server: stateless Streamable HTTP at POST /mcp. mcpAuth wraps +// authenticateUser so a 401 carries a WWW-Authenticate header pointing MCP +// clients at the OAuth discovery document (added in a later commit). +// GET/DELETE are unsupported by this stateless server (no SSE stream to +// resume, no session to terminate) - 405 rather than reaching the transport. +app.post("/mcp", mcpAuth, createMcpPostRoute(app)); +app.get("/mcp", mcpMethodNotAllowed); +app.delete("/mcp", mcpMethodNotAllowed); + // Health / readiness probes - unauthenticated so load balancers and the // troubleshooting docs can verify the server without credentials. app.get("/health", (c) => c.json({ success: true, result: { status: "ok" } })); diff --git a/apps/server/src/mcp/docsSearch.ts b/apps/server/src/mcp/docsSearch.ts new file mode 100644 index 00000000..cb869ddf --- /dev/null +++ b/apps/server/src/mcp/docsSearch.ts @@ -0,0 +1,112 @@ +import { Database } from "bun:sqlite"; +import { existsSync } from "node:fs"; +import { loadConfig } from "../config"; + +export interface DocsSearchRow { + path: string; + url: string; + title: string; + snippet: string; +} + +export type DocsSearchResult = + | { success: true; result: { query: string; matches: DocsSearchRow[] } } + | { success: false; error: string; code?: string }; + +const SITE_URL = "https://devicesdk.com"; + +let cachedDb: Database | null | undefined; + +/** Lazily opens the read-only docs index on first query, caching the handle. */ +function getDb(): Database | null { + if (cachedDb !== undefined) return cachedDb; + + const path = loadConfig().docsIndexPath; + if (!existsSync(path)) { + cachedDb = null; + return cachedDb; + } + try { + cachedDb = new Database(path, { readonly: true }); + } catch { + cachedDb = null; + } + return cachedDb; +} + +/** + * Sanitizes a free-text query for FTS5's own query syntax, which throws on + * stray quotes/operators (a bare `"` or a dangling `AND` is a syntax error, + * not a "no results" case). Splitting into alphanumeric tokens and + * re-quoting each one guarantees a syntactically valid MATCH expression + * regardless of what the caller typed. + */ +function toFtsQuery(query: string, joiner: "AND" | "OR"): string { + const tokens = query + .split(/[^A-Za-z0-9]+/) + .filter((t) => t.length > 0) + .map((t) => `"${t}"`); + return tokens.join(` ${joiner} `); +} + +const SEARCH_SQL = + "SELECT path, title, snippet(docs_fts, 3, '[', ']', ' … ', 16) AS snippet " + + "FROM docs_fts WHERE docs_fts MATCH ?1 ORDER BY rank LIMIT 10"; + +/** + * Full-text search over the local docs index. Never throws - a missing index + * (dev checkout without a build) or an unparseable query both come back as a + * structured `{ success: false }` result, matching the same convention every + * other devicesdk_* tool uses. + */ +export function searchDocs(query: string): DocsSearchResult { + const db = getDb(); + if (!db) { + return { + success: false, + error: + "Docs search index not found. Run the server build " + + "(`bun run scripts/build-docs-index.ts` in apps/server, or `pnpm build`) " + + "to generate dist/docs-index.sqlite, or set DOCS_INDEX_PATH.", + code: "docs_index_missing", + }; + } + + const trimmed = query.trim(); + if (!trimmed) { + return { + success: false, + error: "query is required", + code: "missing_query", + }; + } + + const runQuery = (ftsQuery: string): DocsSearchRow[] => { + if (!ftsQuery) return []; + try { + const rows = db + .query<{ path: string; title: string; snippet: string }, [string]>( + SEARCH_SQL, + ) + .all(ftsQuery); + return rows.map((r) => ({ + path: r.path, + url: `${SITE_URL}${r.path}`, + title: r.title, + snippet: r.snippet, + })); + } catch { + // A pathological query (e.g. a single FTS5 operator token) can still + // fail to parse even after quoting; treat as "no results" rather than + // propagating a SQLite error to the caller. + return []; + } + }; + + let matches = runQuery(toFtsQuery(trimmed, "AND")); + if (matches.length === 0) { + matches = runQuery(toFtsQuery(trimmed, "OR")); + } + + return { success: true, result: { query: trimmed, matches } }; +} diff --git a/apps/server/src/mcp/mcpServer.ts b/apps/server/src/mcp/mcpServer.ts new file mode 100644 index 00000000..978a6904 --- /dev/null +++ b/apps/server/src/mcp/mcpServer.ts @@ -0,0 +1,15 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import pkg from "../../package.json" with { type: "json" }; +import { registerTools, type ToolDeps } from "./tools"; + +/** + * Builds a fresh MCP server instance wired to the given loopback. Called once + * per /mcp request (see route.ts) - the server is stateless, so there is no + * benefit to reusing an instance across requests and every benefit (no shared + * mutable state between callers) to not doing so. + */ +export function buildMcpServer(deps: ToolDeps): McpServer { + const server = new McpServer({ name: "devicesdk", version: pkg.version }); + registerTools(server, deps); + return server; +} diff --git a/apps/server/src/mcp/route.ts b/apps/server/src/mcp/route.ts new file mode 100644 index 00000000..e18a28e7 --- /dev/null +++ b/apps/server/src/mcp/route.ts @@ -0,0 +1,106 @@ +import { StreamableHTTPTransport } from "@hono/mcp"; +import type { AppContext, Env } from "../types"; +import { buildMcpServer } from "./mcpServer"; +import type { LoopbackFn } from "./tools"; + +/** + * Minimal shape this module needs from the top-level Hono app: just + * `.request()`, used to re-enter the REST API in-process (see tools.ts). + * Deliberately structural (not `import type { app } from "../index"`) - index.ts + * mounts this route, so importing the `app` value here would be a module + * import cycle. index.ts passes its own `app` in when it calls + * `createMcpPostRoute(app)`; any object with a Hono-shaped `.request()` works. + */ +export interface AppRequester { + request: ( + input: string, + requestInit?: RequestInit, + env?: Env, + ) => Response | Promise; +} + +function buildLoopback(appLike: AppRequester, c: AppContext): LoopbackFn { + // Forward whatever credential authenticated the outer /mcp call (Bearer + // token or, harmlessly, a same-origin session cookie) so the inner REST + // call authenticates as the same user. + const authHeader = c.req.header("Authorization"); + const cookieHeader = c.req.header("Cookie"); + + return async (method, path, opts) => { + const url = new URL(path, "http://internal.invalid"); + if (opts?.query) { + for (const [key, value] of Object.entries(opts.query)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + } + + const headers: Record = {}; + if (authHeader) headers.Authorization = authHeader; + if (cookieHeader) headers.Cookie = cookieHeader; + + let body: string | undefined; + if (opts?.body !== undefined) { + body = JSON.stringify(opts.body); + headers["Content-Type"] = "application/json"; + } + + const res = await appLike.request( + `${url.pathname}${url.search}`, + { method, headers, body }, + c.env, + ); + const text = await res.text(); + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + parsed = undefined; + } + return { status: res.status, body: parsed }; + }; +} + +/** + * Builds the `POST /mcp` handler. Stateless: a fresh McpServer + transport is + * constructed per request (no `Mcp-Session-Id`, no server-to-client push + * streams), so there is no cross-request state to leak between callers or + * clean up between requests. + */ +export function createMcpPostRoute(appLike: AppRequester) { + return async (c: AppContext) => { + const loopback = buildLoopback(appLike, c); + const server = buildMcpServer({ loopback }); + const transport = new StreamableHTTPTransport({ + // Stateless mode (MCP SDK): no session id is generated or validated. + sessionIdGenerator: undefined, + // Plain JSON responses - fitting for a stateless server with no + // server-initiated push, and simpler for every client to consume. + enableJsonResponse: true, + }); + await server.connect(transport); + const res = await transport.handleRequest(c); + // The MCP SDK's own type says `Response | undefined`; every branch in + // the POST handler we rely on (stateless, enableJsonResponse) actually + // returns a Response, but keep a defensive fallback rather than `!`. + return ( + res ?? + c.json( + { success: false, error: "MCP transport produced no response" }, + 500, + ) + ); + }; +} + +/** GET and DELETE are not supported by this stateless server. */ +export function mcpMethodNotAllowed(c: AppContext) { + return c.json( + { + jsonrpc: "2.0", + error: { code: -32000, message: "Method not allowed." }, + id: null, + }, + 405, + { Allow: "POST" }, + ); +} diff --git a/apps/server/src/mcp/tools.ts b/apps/server/src/mcp/tools.ts new file mode 100644 index 00000000..fe0882e2 --- /dev/null +++ b/apps/server/src/mcp/tools.ts @@ -0,0 +1,453 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { searchDocs } from "./docsSearch"; + +/** Result of an in-process loopback call against the REST API. */ +export interface LoopbackResult { + status: number; + /** Parsed JSON body, or undefined if the response wasn't JSON. */ + body: unknown; +} + +/** + * Re-enters the Hono app for a given method/path as the same authenticated + * user that called /mcp - see `route.ts` for how this is constructed. Tools + * never talk to the database directly; they wrap the existing REST API so + * validation, limits, and response shapes stay in exactly one place. + */ +export type LoopbackFn = ( + method: string, + path: string, + opts?: { + query?: Record; + body?: unknown; + }, +) => Promise; + +export interface ToolDeps { + loopback: LoopbackFn; +} + +interface ApiEnvelope { + success?: boolean; + [key: string]: unknown; +} + +/** + * Converts a loopback REST response into an MCP tool result: the JSON body + * pretty-printed as text content, with `isError` mirroring the REST envelope's + * `success` flag - the same convention the retired packages/mcp stdio server + * used (JSON.stringify(result, null, 2) + isError: !result.success). + */ +function toToolResult(result: LoopbackResult): CallToolResult { + const body = ( + result.body === undefined || result.body === null + ? { + success: false, + error: `Empty or non-JSON response (HTTP ${result.status})`, + } + : result.body + ) as ApiEnvelope; + return { + content: [{ type: "text", text: JSON.stringify(body, null, 2) }], + isError: body.success !== true, + }; +} + +// Every project/device path parameter is the URL *slug* (project_slug / +// device_slug) - the same identifiers devicesdk_list_projects / +// devicesdk_list_devices return as `project_slug` / `device_id` in their +// result payloads - NOT the internal `id` UUID also present in those list +// responses. Every description below says so explicitly so an agent doesn't +// feed a UUID and get 404s. +const projectIdParam = z + .string() + .min(1) + .max(36) + .describe( + "Project slug (the project_slug field from devicesdk_list_projects - not the id UUID).", + ); +const deviceIdParam = z + .string() + .min(1) + .max(36) + .describe( + "Device slug (the device_id field from devicesdk_list_devices - not the id UUID).", + ); +const windowParam = z + .enum(["1h", "12h", "7d"]) + .optional() + .describe( + "Metrics time window. Defaults to 1h for a device, 12h for a project.", + ); + +// Mirrors sendCommand.ts's VALID_COMMAND_TYPES. The REST endpoint remains the +// source of truth for validation (an unrecognized type still comes back as a +// clear 400 through the loopback); this local copy exists only so the tool's +// JSON schema documents the legal values to the calling agent. +const COMMAND_TYPES = [ + "set_gpio_state", + "get_pin_state", + "set_pwm_state", + "set_pin_config", + "i2c_scan", + "i2c_read", + "i2c_write", + "i2c_configure", + "i2c_batch_write", + "display_update", + "configure_gpio_input_monitoring", + "reboot", + "get_temperature", + "watchdog_configure", + "watchdog_feed", + "spi_configure", + "spi_transfer", + "spi_write", + "spi_read", + "uart_configure", + "uart_write", + "uart_read", + "pio_ws2812_configure", + "pio_ws2812_update", +] as const; + +/** Registers every devicesdk_* tool on `server`, wired to the given loopback. */ +export function registerTools(server: McpServer, deps: ToolDeps): void { + const { loopback } = deps; + + // --- Read-only tools ----------------------------------------------- + + server.registerTool( + "devicesdk_whoami", + { + title: "Who am I", + description: + "Show the currently-authenticated DeviceSDK user: id, email, name, plan limits, and usage.", + inputSchema: {}, + annotations: { readOnlyHint: true, title: "Who am I" }, + }, + async () => toToolResult(await loopback("GET", "/v1/user/me")), + ); + + server.registerTool( + "devicesdk_list_projects", + { + title: "List projects", + description: + "List every project owned by the authenticated user, paginated. Each item's " + + "project_slug is what you pass as projectId to every other tool - id is an " + + "internal UUID, not usable elsewhere.", + inputSchema: { + page: z.coerce.number().int().min(1).optional(), + per_page: z.coerce.number().int().min(1).max(100).optional(), + }, + annotations: { readOnlyHint: true, title: "List projects" }, + }, + async ({ page, per_page }) => + toToolResult( + await loopback("GET", "/v1/projects", { query: { page, per_page } }), + ), + ); + + server.registerTool( + "devicesdk_list_devices", + { + title: "List devices", + description: + "List devices in a project, paginated. Each item's device_id field is the " + + "device's slug (what you pass as deviceId to other tools) - id is an internal " + + "UUID, not usable elsewhere.", + inputSchema: { + projectId: projectIdParam, + page: z.coerce.number().int().min(1).optional(), + per_page: z.coerce.number().int().min(1).max(100).optional(), + }, + annotations: { readOnlyHint: true, title: "List devices" }, + }, + async ({ projectId, page, per_page }) => + toToolResult( + await loopback("GET", `/v1/projects/${projectId}/devices`, { + query: { page, per_page }, + }), + ), + ); + + server.registerTool( + "devicesdk_device_status", + { + title: "Device connection status", + description: + "Get a device's live WebSocket connection status: connected, connected_since, " + + "last_connected_at, and the currently deployed script version.", + inputSchema: { projectId: projectIdParam, deviceId: deviceIdParam }, + annotations: { readOnlyHint: true, title: "Device connection status" }, + }, + async ({ projectId, deviceId }) => + toToolResult( + await loopback( + "GET", + `/v1/projects/${projectId}/devices/${deviceId}/status`, + ), + ), + ); + + server.registerTool( + "devicesdk_device_logs", + { + title: "Device logs", + description: + "Fetch device logs. NOTE: as of the current server version this REST endpoint " + + "is deprecated and always returns a 410 response pointing at the live watcher " + + "WebSocket (/v1/projects/:projectId/devices/:deviceId/watch) instead of a log " + + "page - there is no point-in-time log snapshot API today. Kept as a tool for " + + "API symmetry; expect isError:true with a LOGS_DEPRECATED code.", + inputSchema: { + projectId: projectIdParam, + deviceId: deviceIdParam, + cursor: z.string().optional(), + limit: z.coerce.number().min(1).max(100).optional(), + level: z.enum(["log", "info", "warn", "error", "debug"]).optional(), + }, + annotations: { readOnlyHint: true, title: "Device logs" }, + }, + async ({ projectId, deviceId, cursor, limit, level }) => + toToolResult( + await loopback( + "GET", + `/v1/projects/${projectId}/devices/${deviceId}/logs`, + { query: { cursor, limit, level } }, + ), + ), + ); + + server.registerTool( + "devicesdk_device_metrics", + { + title: "Device usage metrics", + description: + "Get time-bucketed usage metrics (messages, bytes, commands) for a single device.", + inputSchema: { + projectId: projectIdParam, + deviceId: deviceIdParam, + window: windowParam, + }, + annotations: { readOnlyHint: true, title: "Device usage metrics" }, + }, + async ({ projectId, deviceId, window }) => + toToolResult( + await loopback( + "GET", + `/v1/projects/${projectId}/devices/${deviceId}/metrics`, + { query: { window } }, + ), + ), + ); + + server.registerTool( + "devicesdk_project_metrics", + { + title: "Project usage metrics", + description: + "Get aggregated usage metrics for every device in a project, plus project totals.", + inputSchema: { projectId: projectIdParam, window: windowParam }, + annotations: { readOnlyHint: true, title: "Project usage metrics" }, + }, + async ({ projectId, window }) => + toToolResult( + await loopback("GET", `/v1/projects/${projectId}/metrics`, { + query: { window }, + }), + ), + ); + + server.registerTool( + "devicesdk_env_list", + { + title: "List env var keys", + description: + "List environment variable keys set on a project. Values are never returned by " + + "the API (for security) - only keys and their last-updated timestamps.", + inputSchema: { projectId: projectIdParam }, + annotations: { readOnlyHint: true, title: "List env var keys" }, + }, + async ({ projectId }) => + toToolResult(await loopback("GET", `/v1/projects/${projectId}/env`)), + ); + + server.registerTool( + "devicesdk_list_script_versions", + { + title: "List script versions", + description: + "List every uploaded script version for a device, newest first, flagging which " + + "one is currently deployed (is_current).", + inputSchema: { projectId: projectIdParam, deviceId: deviceIdParam }, + annotations: { readOnlyHint: true, title: "List script versions" }, + }, + async ({ projectId, deviceId }) => + toToolResult( + await loopback( + "GET", + `/v1/projects/${projectId}/devices/${deviceId}/script/versions`, + ), + ), + ); + + server.registerTool( + "devicesdk_docs_search", + { + title: "Search DeviceSDK docs", + description: + "Full-text search over a local, offline copy of the DeviceSDK docs matching " + + "this server's version (no internet call; results can lag the live site until " + + "the server is updated). Returns ranked { path, url, title, snippet } rows.", + inputSchema: { query: z.string().min(1) }, + annotations: { readOnlyHint: true, title: "Search DeviceSDK docs" }, + }, + async ({ query }) => { + const result = searchDocs(query); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + isError: !result.success, + }; + }, + ); + + // --- Mutating tools -------------------------------------------------- + + server.registerTool( + "devicesdk_env_set", + { + title: "Set env vars", + description: + "Set one or more environment variables on a project. Values become visible to " + + "the device script via this.env.VARS.get(key).", + inputSchema: { + projectId: projectIdParam, + vars: z + .record(z.string(), z.string()) + .describe("Map of KEY (uppercase, digits, underscores) to value."), + }, + annotations: { title: "Set env vars" }, + }, + async ({ projectId, vars }) => + toToolResult( + await loopback("PUT", `/v1/projects/${projectId}/env`, { + body: { vars }, + }), + ), + ); + + server.registerTool( + "devicesdk_env_delete", + { + title: "Delete an env var", + description: "Delete a single environment variable key from a project.", + inputSchema: { projectId: projectIdParam, key: z.string().min(1) }, + annotations: { destructiveHint: true, title: "Delete an env var" }, + }, + async ({ projectId, key }) => + toToolResult( + await loopback( + "DELETE", + `/v1/projects/${projectId}/env/${encodeURIComponent(key)}`, + ), + ), + ); + + server.registerTool( + "devicesdk_send_command", + { + title: "Send a device command", + description: + "Send a hardware command to a connected device and wait for its response " + + "(GPIO, PWM, I2C, SPI, UART, display, watchdog, reboot, ...). Fails with 503 if " + + "the device isn't currently connected, 504 if it doesn't answer in time.", + inputSchema: { + projectId: projectIdParam, + deviceId: deviceIdParam, + type: z + .enum(COMMAND_TYPES) + .describe("Command type - see the device command reference docs."), + payload: z + .record(z.string(), z.unknown()) + .optional() + .describe("Command-specific payload object (max 4KB serialized)."), + }, + annotations: { title: "Send a device command" }, + }, + async ({ projectId, deviceId, type, payload }) => + toToolResult( + await loopback( + "POST", + `/v1/projects/${projectId}/devices/${deviceId}/command`, + { body: { type, payload: payload ?? {} } }, + ), + ), + ); + + server.registerTool( + "devicesdk_upload_script", + { + title: "Upload a script version", + description: + "Upload a new script version for a device and deploy it immediately. `script` " + + "must be already-bundled JavaScript (a single file, no bare imports) - build " + + "TypeScript device projects with `devicesdk build` or the CLI deploy flow first; " + + "this tool cannot bundle TypeScript itself.", + inputSchema: { + projectId: projectIdParam, + deviceId: deviceIdParam, + script: z.string().min(1).describe("Bundled JavaScript source."), + entrypoint: z + .string() + .min(1) + .max(255) + .describe( + "Exported class name to instantiate (a valid JS identifier).", + ), + message: z + .string() + .max(500) + .optional() + .describe( + "Optional version note shown in devicesdk_list_script_versions.", + ), + }, + annotations: { title: "Upload a script version" }, + }, + async ({ projectId, deviceId, script, entrypoint, message }) => + toToolResult( + await loopback( + "PUT", + `/v1/projects/${projectId}/devices/${deviceId}/script`, + { body: { script, entrypoint, message } }, + ), + ), + ); + + server.registerTool( + "devicesdk_deploy_version", + { + title: "Deploy a script version", + description: + "Activate a previously-uploaded script version on a device (rollback or " + + "promote) - the version must already exist per devicesdk_list_script_versions.", + inputSchema: { + projectId: projectIdParam, + deviceId: deviceIdParam, + versionId: z.string().min(1).max(36), + }, + annotations: { title: "Deploy a script version" }, + }, + async ({ projectId, deviceId, versionId }) => + toToolResult( + await loopback( + "POST", + `/v1/projects/${projectId}/devices/${deviceId}/script/versions/${versionId}/deploy`, + ), + ), + ); +} From c820013e504c697fcec559dda238254c8a06811c Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:51:50 +0000 Subject: [PATCH 04/16] feat(server): oauth 2.1 authorization for MCP clients Adds a minimal OAuth 2.1 authorization server hosted by the same process: authorization-code grant with mandatory PKCE (S256), open (rate-limited) dynamic client registration (RFC 7591), no refresh tokens - clients re-run the flow after the 30-day access token expires. Access tokens are managed API tokens (revocable in the dashboard's Tokens page), minted with description "MCP: ". - oauth/metadata.ts: RFC 9728 protected-resource + RFC 8414 authorization-server discovery documents, issuer/endpoints derived from the request origin. - oauth/register.ts: POST /oauth/register, permissive redirect_uri validation (https, any http including non-loopback - this is a LAN self-host product - or a custom scheme for native clients). - oauth/authorizePage.ts: GET/POST /oauth/authorize consent screen behind cliAuthUser, modeled on endpoints/cli-auth/approvalPage.ts (CSRF cookie, same card UI). Client/redirect_uri mismatches render an error page rather than redirecting (OAuth security BCP); every other validation failure redirects back to the now-trusted redirect_uri with ?error=. - oauth/token.ts: POST /oauth/token, authorization_code grant only, RFC 6749 error shape. Validates the code, PKCE, client_id, and redirect_uri (in that order) before deleting the code and minting the token, so a client that makes a correctable mistake isn't locked out of its still-valid code. - oauth/store.ts: shared DB helpers + the RFC 6749/7591 JSON error helper. Verified end to end against a live server: DCR (success + invalid_client_metadata), full authorize -> consent -> PKCE token exchange, code single-use and PKCE/ redirect_uri mismatch rejections, deny path, minted-token visibility in /v1/tokens (managed:true) and revocation via DELETE locking out /mcp, and the auth.ts expiry regression case both ways (NULL-expiry token keeps working; forced-expiry token 401s on both REST and /mcp). Part of plan 003 (bundled MCP server + OAuth 2.1). --- apps/server/src/index.ts | 29 +- apps/server/src/oauth/authorizePage.ts | 350 +++++++++++++++++++++++++ apps/server/src/oauth/metadata.ts | 38 +++ apps/server/src/oauth/register.ts | 79 ++++++ apps/server/src/oauth/store.ts | 176 +++++++++++++ apps/server/src/oauth/token.ts | 165 ++++++++++++ 6 files changed, 836 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/oauth/authorizePage.ts create mode 100644 apps/server/src/oauth/metadata.ts create mode 100644 apps/server/src/oauth/register.ts create mode 100644 apps/server/src/oauth/store.ts create mode 100644 apps/server/src/oauth/token.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 14a0c870..fb88c83d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -34,6 +34,13 @@ import { import { logger } from "./foundation/logger"; import { rateLimitMiddleware } from "./foundation/rateLimit"; import { createMcpPostRoute, mcpMethodNotAllowed } from "./mcp/route"; +import { getAuthorizePage, postAuthorizeAction } from "./oauth/authorizePage"; +import { + authorizationServerMetadata, + protectedResourceMetadata, +} from "./oauth/metadata"; +import { handleRegisterClient } from "./oauth/register"; +import { handleTokenExchange } from "./oauth/token"; import { serveSpa } from "./spa"; import type { Env, Variables } from "./types"; @@ -142,13 +149,33 @@ app.post("/cli/auth", cliAuthUser, handleApproval); // Bundled MCP server: stateless Streamable HTTP at POST /mcp. mcpAuth wraps // authenticateUser so a 401 carries a WWW-Authenticate header pointing MCP -// clients at the OAuth discovery document (added in a later commit). +// clients at the OAuth discovery document below. // GET/DELETE are unsupported by this stateless server (no SSE stream to // resume, no session to terminate) - 405 rather than reaching the transport. app.post("/mcp", mcpAuth, createMcpPostRoute(app)); app.get("/mcp", mcpMethodNotAllowed); app.delete("/mcp", mcpMethodNotAllowed); +// OAuth 2.1 authorization server for MCP clients (additive to static API +// tokens - see foundation/auth.ts). Discovery metadata is unauthenticated; +// /oauth/authorize requires a dashboard session (cliAuthUser redirects to +// login otherwise, exactly like /cli/auth above); /oauth/register and +// /oauth/token are unauthenticated but rate-limited. +app.get("/.well-known/oauth-protected-resource", protectedResourceMetadata); +// RFC 9728 section 3 path-suffixed form, for clients that request the +// metadata document scoped to the specific resource path. +app.get("/.well-known/oauth-protected-resource/mcp", protectedResourceMetadata); +app.get("/.well-known/oauth-authorization-server", authorizationServerMetadata); + +app.use("/oauth/register", rateLimitMiddleware(10, 60_000)); +app.post("/oauth/register", handleRegisterClient); + +app.get("/oauth/authorize", cliAuthUser, getAuthorizePage); +app.post("/oauth/authorize", cliAuthUser, postAuthorizeAction); + +app.use("/oauth/token", rateLimitMiddleware(30, 60_000)); +app.post("/oauth/token", handleTokenExchange); + // Health / readiness probes - unauthenticated so load balancers and the // troubleshooting docs can verify the server without credentials. app.get("/health", (c) => c.json({ success: true, result: { status: "ok" } })); diff --git a/apps/server/src/oauth/authorizePage.ts b/apps/server/src/oauth/authorizePage.ts new file mode 100644 index 00000000..9863c2e7 --- /dev/null +++ b/apps/server/src/oauth/authorizePage.ts @@ -0,0 +1,350 @@ +import { getCookie, setCookie } from "hono/cookie"; +import { html, raw } from "hono/html"; +import type { AppContext } from "../types"; +import { getClient, insertAuthCode, type OauthClient } from "./store"; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function renderPage(title: string, content: string) { + return html` + + + + + ${raw(title)} - DeviceSDK + + + +
${raw(content)}
+ + `; +} + +function renderErrorPage(message: string) { + return renderPage( + "Error", + ` +
+
+

Error

+

${escapeHtml(message)}

+
+ `, + ); +} + +interface ConsentFields { + responseType: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + codeChallengeMethod: string; + state: string | undefined; +} + +function hiddenField(name: string, value: string): string { + return ``; +} + +function renderConsentPage( + client: OauthClient, + userEmail: string, + csrfToken: string, + fields: ConsentFields, +) { + const hidden = [ + hiddenField("response_type", fields.responseType), + hiddenField("client_id", fields.clientId), + hiddenField("redirect_uri", fields.redirectUri), + hiddenField("code_challenge", fields.codeChallenge), + hiddenField("code_challenge_method", fields.codeChallengeMethod), + fields.state !== undefined ? hiddenField("state", fields.state) : "", + hiddenField("csrf_token", csrfToken), + ].join("\n"); + + return renderPage( + "Authorize", + ` +

Authorize ${escapeHtml(client.client_name)}

+

${escapeHtml(client.client_name)} wants to access your + DeviceSDK server as ${escapeHtml(userEmail)}.

+ +
+ Approving creates an API token you can revoke anytime in the dashboard's + Tokens page. It expires automatically after 30 days. +
+ +
+ ${hidden} +
+ + +
+
+ `, + ); +} + +interface ValidAuthorize extends ConsentFields { + kind: "valid"; + client: OauthClient; +} +interface ClientError { + kind: "client_error"; +} +interface RedirectableError { + kind: "redirect_error"; + redirectUri: string; + state: string | undefined; + error: string; +} +type AuthorizeValidation = ValidAuthorize | ClientError | RedirectableError; + +/** + * Validates an /oauth/authorize request (shared by GET and POST - POST + * re-validates every field rather than trusting the hidden form fields + * blindly). Client/redirect_uri mismatches are NOT redirectable (per OAuth + * security BCP - we cannot trust an unregistered redirect target); every + * other validation failure redirects back to the now-trusted redirect_uri + * with `?error=`. + */ +async function validateAuthorizeRequest( + c: AppContext, + params: Record, +): Promise { + const clientId = params.client_id; + const redirectUri = params.redirect_uri; + const state = params.state || undefined; + + if (!clientId || !redirectUri) return { kind: "client_error" }; + const client = await getClient(c, clientId); + if (!client) return { kind: "client_error" }; + if (!client.redirect_uris.includes(redirectUri)) + return { kind: "client_error" }; + + if (params.response_type !== "code") { + return { + kind: "redirect_error", + redirectUri, + state, + error: "unsupported_response_type", + }; + } + if (params.code_challenge_method !== "S256") { + return { + kind: "redirect_error", + redirectUri, + state, + error: "invalid_request", + }; + } + const codeChallenge = params.code_challenge ?? ""; + if (codeChallenge.length < 43 || codeChallenge.length > 128) { + return { + kind: "redirect_error", + redirectUri, + state, + error: "invalid_request", + }; + } + + return { + kind: "valid", + client, + responseType: "code", + clientId, + redirectUri, + codeChallenge, + codeChallengeMethod: "S256", + state, + }; +} + +function redirectWithError(c: AppContext, err: RedirectableError) { + const url = new URL(err.redirectUri); + url.searchParams.set("error", err.error); + if (err.state !== undefined) url.searchParams.set("state", err.state); + return c.redirect(url.toString()); +} + +const CSRF_COOKIE_NAME = "oauth_csrf"; + +/** GET /oauth/authorize - mounted behind cliAuthUser (redirects to login if unauthenticated). */ +export async function getAuthorizePage(c: AppContext) { + const user = c.get("user"); + const validation = await validateAuthorizeRequest(c, c.req.query()); + + if (validation.kind === "client_error") { + return c.html( + renderErrorPage( + "This request could not be verified (unknown client_id or an unregistered redirect_uri). Contact the application's developer.", + ), + 400, + ); + } + if (validation.kind === "redirect_error") { + return redirectWithError(c, validation); + } + + const csrfToken = crypto.randomUUID(); + setCookie(c, CSRF_COOKIE_NAME, csrfToken, { + path: "/oauth/authorize", + httpOnly: true, + sameSite: "Strict", + secure: c.env.ENV !== "local", + maxAge: 600, + }); + + return c.html( + renderConsentPage(validation.client, user.email, csrfToken, validation), + ); +} + +/** POST /oauth/authorize - mounted behind cliAuthUser; approve/deny the consent form. */ +export async function postAuthorizeAction(c: AppContext) { + const user = c.get("user"); + const formData = await c.req.parseBody(); + const field = (key: string): string | undefined => { + const value = formData[key]; + return typeof value === "string" ? value : undefined; + }; + + const csrfFromForm = field("csrf_token"); + const csrfFromCookie = getCookie(c, CSRF_COOKIE_NAME); + if (!csrfFromForm || !csrfFromCookie || csrfFromForm !== csrfFromCookie) { + return c.html(renderErrorPage("Invalid request. Please try again."), 403); + } + + const validation = await validateAuthorizeRequest(c, { + response_type: field("response_type"), + client_id: field("client_id"), + redirect_uri: field("redirect_uri"), + code_challenge: field("code_challenge"), + code_challenge_method: field("code_challenge_method"), + state: field("state"), + }); + + if (validation.kind === "client_error") { + return c.html( + renderErrorPage( + "This request could not be verified. Please restart the authorization flow.", + ), + 400, + ); + } + if (validation.kind === "redirect_error") { + return redirectWithError(c, validation); + } + + if (field("action") !== "approve") { + return redirectWithError(c, { + kind: "redirect_error", + redirectUri: validation.redirectUri, + state: validation.state, + error: "access_denied", + }); + } + + const code = await insertAuthCode(c, { + clientId: validation.clientId, + userId: user.id, + redirectUri: validation.redirectUri, + codeChallenge: validation.codeChallenge, + }); + + const url = new URL(validation.redirectUri); + url.searchParams.set("code", code); + if (validation.state !== undefined) + url.searchParams.set("state", validation.state); + return c.redirect(url.toString()); +} diff --git a/apps/server/src/oauth/metadata.ts b/apps/server/src/oauth/metadata.ts new file mode 100644 index 00000000..a18abdfd --- /dev/null +++ b/apps/server/src/oauth/metadata.ts @@ -0,0 +1,38 @@ +import type { AppContext } from "../types"; + +function originOf(c: AppContext): string { + return new URL(c.req.url).origin; +} + +/** + * RFC 9728 OAuth protected resource metadata. Unauthenticated, so a 401 from + * /mcp (see foundation/auth.ts's mcpAuth) can point clients here via + * WWW-Authenticate to bootstrap OAuth discovery. + */ +export function protectedResourceMetadata(c: AppContext) { + const origin = originOf(c); + return c.json({ + resource: `${origin}/mcp`, + authorization_servers: [origin], + bearer_methods_supported: ["header"], + }); +} + +/** + * RFC 8414 OAuth authorization server metadata. The issuer and every endpoint + * URL are derived from the incoming request so this works unmodified for + * devicesdk.local, a bare LAN IP, or a reverse-proxied hostname. + */ +export function authorizationServerMetadata(c: AppContext) { + const origin = originOf(c); + return c.json({ + issuer: origin, + authorization_endpoint: `${origin}/oauth/authorize`, + token_endpoint: `${origin}/oauth/token`, + registration_endpoint: `${origin}/oauth/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); +} diff --git a/apps/server/src/oauth/register.ts b/apps/server/src/oauth/register.ts new file mode 100644 index 00000000..4b274acc --- /dev/null +++ b/apps/server/src/oauth/register.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; +import type { AppContext } from "../types"; +import { insertClient, oauthJsonError } from "./store"; + +const RegisterSchema = z.object({ + client_name: z.string().max(100).optional(), + redirect_uris: z.array(z.string()).min(1), +}); + +/** + * Every scheme is accepted as long as it parses as an absolute URI: `https` + * (recommended), `http` (including non-loopback hosts - this is a LAN + * self-host product where the MCP client's own host may itself be a + * non-TLS box on the network, not just localhost), or a custom scheme like + * `cursor://` for native-app redirect handlers. There is no allowlist of + * hosts; DCR is inherently "trust on first use" for a single-user-grade + * self-hosted server, and the rate limiter on this route bounds abuse. + */ +function isValidRedirectUri(raw: string): boolean { + try { + const url = new URL(raw); + return url.protocol.length > 1; + } catch { + return false; + } +} + +/** POST /oauth/register - RFC 7591 dynamic client registration. */ +export async function handleRegisterClient(c: AppContext) { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return oauthJsonError( + c, + 400, + "invalid_client_metadata", + "Request body must be JSON.", + ); + } + + const parsed = RegisterSchema.safeParse(body); + if (!parsed.success) { + return oauthJsonError( + c, + 400, + "invalid_client_metadata", + "redirect_uris (non-empty array of strings) is required.", + ); + } + + const { redirect_uris } = parsed.data; + if (!redirect_uris.every(isValidRedirectUri)) { + return oauthJsonError( + c, + 400, + "invalid_client_metadata", + "Every redirect_uri must be an absolute URI.", + ); + } + + const clientName = parsed.data.client_name?.trim() || "MCP client"; + const client = await insertClient(c, { + clientName, + redirectUris: redirect_uris, + }); + + return c.json( + { + client_id: client.id, + client_name: client.client_name, + redirect_uris: client.redirect_uris, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + }, + 201, + ); +} diff --git a/apps/server/src/oauth/store.ts b/apps/server/src/oauth/store.ts new file mode 100644 index 00000000..457ee989 --- /dev/null +++ b/apps/server/src/oauth/store.ts @@ -0,0 +1,176 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { hashToken } from "../foundation/tokenHash"; +import type { + AppContext, + tableOauthAuthCodes, + tableOauthClients, +} from "../types"; + +export const AUTH_CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes +export const ACCESS_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +export const ACCESS_TOKEN_TTL_SECONDS = Math.floor(ACCESS_TOKEN_TTL_MS / 1000); + +export interface OauthClient { + id: string; + client_name: string; + redirect_uris: string[]; + created_at: number; +} + +function parseClientRow(row: tableOauthClients): OauthClient { + let redirectUris: string[] = []; + try { + const parsed = JSON.parse(row.redirect_uris); + if (Array.isArray(parsed)) + redirectUris = parsed.filter((u) => typeof u === "string"); + } catch { + redirectUris = []; + } + return { + id: row.id, + client_name: row.client_name, + redirect_uris: redirectUris, + created_at: row.created_at, + }; +} + +/** RFC 7591 dynamic client registration: insert a new OAuth client. */ +export async function insertClient( + c: AppContext, + params: { clientName: string; redirectUris: string[] }, +): Promise { + const id = crypto.randomUUID(); + const created_at = Date.now(); + await c + .get("qb") + .insert({ + tableName: "oauth_clients", + data: { + id, + client_name: params.clientName, + redirect_uris: JSON.stringify(params.redirectUris), + created_at, + }, + }) + .execute(); + return { + id, + client_name: params.clientName, + redirect_uris: params.redirectUris, + created_at, + }; +} + +export async function getClient( + c: AppContext, + clientId: string, +): Promise { + const res = await c + .get("qb") + .fetchOne({ + tableName: "oauth_clients", + where: { conditions: ["id = ?1"], params: [clientId] }, + }) + .execute(); + return res.results ? parseClientRow(res.results) : null; +} + +export interface StoredAuthCode { + id: string; + client_id: string; + user_id: string; + redirect_uri: string; + code_challenge: string; +} + +/** Mints a fresh authorization code (10 min TTL) and stores only its HMAC hash. */ +export async function insertAuthCode( + c: AppContext, + params: { + clientId: string; + userId: string; + redirectUri: string; + codeChallenge: string; + }, +): Promise { + const rawCode = crypto.randomUUID().replaceAll("-", ""); + const codeHash = await hashToken(rawCode, c.env.config.apiTokenSecret); + const now = Date.now(); + await c + .get("qb") + .insert({ + tableName: "oauth_auth_codes", + data: { + id: crypto.randomUUID(), + code_hash: codeHash, + client_id: params.clientId, + user_id: params.userId, + redirect_uri: params.redirectUri, + code_challenge: params.codeChallenge, + created_at: now, + expires_at: now + AUTH_CODE_TTL_MS, + }, + }) + .execute(); + return rawCode; +} + +/** + * Looks up an unexpired authorization code by its raw value. Does NOT delete + * it - the token endpoint verifies PKCE + client_id + redirect_uri first and + * only calls `deleteAuthCode` once every check has passed, so a legitimate + * client that made a mistake (e.g. redirect_uri typo) can still retry within + * the 10-minute window instead of being locked out by its own error. + */ +export async function findAuthCodeByRawCode( + c: AppContext, + rawCode: string, +): Promise { + const codeHash = await hashToken(rawCode, c.env.config.apiTokenSecret); + const res = await c + .get("qb") + .fetchOne({ + tableName: "oauth_auth_codes", + where: { + conditions: ["code_hash = ?1", "expires_at > ?2"], + params: [codeHash, Date.now()], + }, + }) + .execute(); + if (!res.results) return null; + return { + id: res.results.id, + client_id: res.results.client_id, + user_id: res.results.user_id, + redirect_uri: res.results.redirect_uri, + code_challenge: res.results.code_challenge, + }; +} + +/** Deletes an authorization code by row id - call only after a successful exchange (single use). */ +export async function deleteAuthCode(c: AppContext, id: string): Promise { + await c + .get("qb") + .delete({ + tableName: "oauth_auth_codes", + where: { conditions: ["id = ?1"], params: [id] }, + }) + .execute(); +} + +/** + * RFC 6749 / RFC 7591 error response shape - deliberately NOT the + * `{success,error}` envelope every other endpoint uses; OAuth clients and the + * spec expect `{ error, error_description? }`. + */ +export function oauthJsonError( + c: AppContext, + status: ContentfulStatusCode, + error: string, + description?: string, +) { + return c.json( + description ? { error, error_description: description } : { error }, + status, + ); +} diff --git a/apps/server/src/oauth/token.ts b/apps/server/src/oauth/token.ts new file mode 100644 index 00000000..5742edb2 --- /dev/null +++ b/apps/server/src/oauth/token.ts @@ -0,0 +1,165 @@ +import { hashToken } from "../foundation/tokenHash"; +import type { AppContext, tableTokens } from "../types"; +import { + ACCESS_TOKEN_TTL_MS, + ACCESS_TOKEN_TTL_SECONDS, + deleteAuthCode, + findAuthCodeByRawCode, + getClient, + oauthJsonError, +} from "./store"; + +/** Parses either application/x-www-form-urlencoded (required by RFC 6749) or JSON. */ +async function parseTokenRequestBody( + c: AppContext, +): Promise | null> { + const contentType = c.req.header("Content-Type") ?? ""; + try { + if (contentType.includes("application/json")) { + const json = await c.req.json(); + if (typeof json !== "object" || json === null) return null; + const out: Record = {}; + for (const [key, value] of Object.entries( + json as Record, + )) { + if (typeof value === "string") out[key] = value; + } + return out; + } + const text = await c.req.text(); + const params = new URLSearchParams(text); + const out: Record = {}; + for (const [key, value] of params) out[key] = value; + return out; + } catch { + return null; + } +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** PKCE S256 check: base64url(SHA-256(code_verifier)) === stored code_challenge. */ +async function verifyPkce( + codeVerifier: string, + codeChallenge: string, +): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(codeVerifier), + ); + return base64UrlEncode(new Uint8Array(digest)) === codeChallenge; +} + +/** + * POST /oauth/token - authorization_code grant only (no refresh grant; a + * client re-runs the full OAuth flow after the 30-day access token expires). + * RFC 6749 error/response shapes, not the `{success,result}` envelope. + */ +export async function handleTokenExchange(c: AppContext) { + const body = await parseTokenRequestBody(c); + if (!body) { + return oauthJsonError( + c, + 400, + "invalid_request", + "Request body could not be parsed.", + ); + } + + if (body.grant_type !== "authorization_code") { + return oauthJsonError( + c, + 400, + "unsupported_grant_type", + 'Only "authorization_code" is supported.', + ); + } + + const { + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: codeVerifier, + } = body; + if (!code || !redirectUri || !clientId || !codeVerifier) { + return oauthJsonError( + c, + 400, + "invalid_request", + "code, redirect_uri, client_id, and code_verifier are all required.", + ); + } + + const client = await getClient(c, clientId); + if (!client) { + return oauthJsonError(c, 401, "invalid_client", "Unknown client_id."); + } + + const authCode = await findAuthCodeByRawCode(c, code); + if (!authCode) { + return oauthJsonError( + c, + 400, + "invalid_grant", + "Code is invalid, expired, or already used.", + ); + } + if ( + authCode.client_id !== clientId || + authCode.redirect_uri !== redirectUri + ) { + return oauthJsonError( + c, + 400, + "invalid_grant", + "client_id or redirect_uri does not match the authorization request.", + ); + } + const pkceOk = await verifyPkce(codeVerifier, authCode.code_challenge); + if (!pkceOk) { + return oauthJsonError( + c, + 400, + "invalid_grant", + "code_verifier does not match code_challenge.", + ); + } + + // Every check passed - consume the code (single use) before minting. + await deleteAuthCode(c, authCode.id); + + const rawToken = crypto.randomUUID().replaceAll("-", ""); + const tokenHash = await hashToken(rawToken, c.env.config.apiTokenSecret); + const now = Date.now(); + const description = `MCP: ${client.client_name}`.slice(0, 100); + + await c + .get("qb") + .insert({ + tableName: "tokens", + data: { + id: crypto.randomUUID(), + user_id: authCode.user_id, + token_hash: tokenHash, + last_four: rawToken.slice(-4), + created_at: now, + managed: 1, + description, + expires_at: now + ACCESS_TOKEN_TTL_MS, + }, + }) + .execute(); + + return c.json({ + access_token: rawToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_TTL_SECONDS, + }); +} From dd074a38c4c88f7dd2ee5107637682a2966abc2e Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:01:09 +0000 Subject: [PATCH 05/16] test(server): e2e coverage for /mcp + oauth, unit coverage for the docs indexer - tests/e2e/mcp.test.ts: 401+WWW-Authenticate without credentials, GET/DELETE 405, tools/list returns exactly the 15 registered tools, tools/call for whoami/list_projects/env_set+env_list roundtrip/send_command against an unconnected device/docs_search, and cross-user project-list isolation. Builds a real docs index from docs/public in beforeAll so docs_search is exercised against actual content. - tests/e2e/oauth.test.ts: discovery metadata shape, DCR happy path + invalid_client_metadata, full authorize->consent->PKCE token exchange with access token working on /mcp, code single-use / expiry / wrong-verifier / mismatched-redirect_uri all rejecting with invalid_grant, deny path, minted token visible in /v1/tokens (managed:true) with revocation locking out /mcp, and the auth.ts expires_at WHERE-clause regression both directions (forced-expiry token 401s on REST and /mcp; a NULL-expiry token keeps working). - tests/unit/docs-index.test.ts: runs the real indexer against a 3-page fixture dir, asserting page count, path mapping (dir/page.md -> /docs/dir/page/, _index.md -> /docs/), frontmatter passthrough, BM25 ranking (a densely-relevant page outranks a tangential mention), snippet content, and that a query made entirely of FTS5 operator syntax never throws. scripts/build-docs-index.ts: guard the `main()` side effect behind `import.meta.main` so importing the module for its exported helpers (as the new unit test does) no longer rebuilds the production index or calls process.exit() - a latent footgun the previous version had. docsSearch.ts: add resetDocsSearchCache() (mirrors foundation/logger.ts's resetLogger) so tests can point the module at a fresh index instead of being stuck with whatever was cached by an earlier call. Full suite: `bun test src tests` -> 344 pass, 0 fail, across 28 files. Coverage gate (`bun run scripts/coverage-gate.ts`, >=85% required): 89.6% functions, 96.44% lines. Part of plan 003 (bundled MCP server + OAuth 2.1). --- apps/server/scripts/build-docs-index.ts | 7 +- apps/server/src/mcp/docsSearch.ts | 10 + apps/server/tests/e2e/mcp.test.ts | 287 +++++++++++ apps/server/tests/e2e/oauth.test.ts | 568 ++++++++++++++++++++++ apps/server/tests/unit/docs-index.test.ts | 172 +++++++ 5 files changed, 1043 insertions(+), 1 deletion(-) create mode 100644 apps/server/tests/e2e/mcp.test.ts create mode 100644 apps/server/tests/e2e/oauth.test.ts create mode 100644 apps/server/tests/unit/docs-index.test.ts diff --git a/apps/server/scripts/build-docs-index.ts b/apps/server/scripts/build-docs-index.ts index bcd3aae3..70df204c 100644 --- a/apps/server/scripts/build-docs-index.ts +++ b/apps/server/scripts/build-docs-index.ts @@ -307,6 +307,11 @@ function main(): void { } } -main(); +// Only run as a side effect when executed directly (`bun run scripts/build-docs-index.ts`), +// never when a test imports this module for its exported helpers below - a +// bare import must not rebuild the production index or process.exit(). +if (import.meta.main) { + main(); +} export { resolveRoutePath, shouldSkipPage, splitFrontmatter, stripMarkdown }; diff --git a/apps/server/src/mcp/docsSearch.ts b/apps/server/src/mcp/docsSearch.ts index cb869ddf..75f8c87b 100644 --- a/apps/server/src/mcp/docsSearch.ts +++ b/apps/server/src/mcp/docsSearch.ts @@ -34,6 +34,16 @@ function getDb(): Database | null { return cachedDb; } +/** + * Test-only: drops the cached DB handle so a subsequent `searchDocs` call + * re-reads `DOCS_INDEX_PATH` from config instead of reusing whatever was + * cached by an earlier call (mirrors foundation/logger.ts's `resetLogger`). + */ +export function resetDocsSearchCache(): void { + cachedDb?.close(); + cachedDb = undefined; +} + /** * Sanitizes a free-text query for FTS5's own query syntax, which throws on * stray quotes/operators (a bare `"` or a dangling `AND` is a syntax error, diff --git a/apps/server/tests/e2e/mcp.test.ts b/apps/server/tests/e2e/mcp.test.ts new file mode 100644 index 00000000..143a0eae --- /dev/null +++ b/apps/server/tests/e2e/mcp.test.ts @@ -0,0 +1,287 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TestServer, type TestUser } from "../harness"; + +const SCRIPT_PATH = new URL( + "../../scripts/build-docs-index.ts", + import.meta.url, +).pathname; +const DOCS_PUBLIC_DIR = new URL("../../../../docs/public", import.meta.url) + .pathname; + +const MCP_HEADERS = { + "content-type": "application/json", + accept: "application/json, text/event-stream", +}; + +const EXPECTED_TOOLS = [ + "devicesdk_whoami", + "devicesdk_list_projects", + "devicesdk_list_devices", + "devicesdk_device_status", + "devicesdk_device_logs", + "devicesdk_device_metrics", + "devicesdk_project_metrics", + "devicesdk_env_list", + "devicesdk_list_script_versions", + "devicesdk_docs_search", + "devicesdk_env_set", + "devicesdk_env_delete", + "devicesdk_send_command", + "devicesdk_upload_script", + "devicesdk_deploy_version", +].sort(); + +interface McpEnvelope { + jsonrpc: string; + id: number; + result?: T; +} +interface ToolsListResult { + tools: { name: string }[]; +} +interface ToolCallResult { + content: { type: string; text: string }[]; + isError: boolean; +} + +function rpc(method: string, params: unknown, id = 1) { + return { jsonrpc: "2.0", id, method, params }; +} + +function toolCall(name: string, args: Record = {}) { + return rpc("tools/call", { name, arguments: args }); +} + +/** Parses a tools/call result's content[0].text as the wrapped REST envelope. */ +function parseToolBody(body: McpEnvelope): { + success: boolean; + [key: string]: unknown; +} { + const text = body.result?.content[0]?.text ?? "{}"; + return JSON.parse(text); +} + +let srv: TestServer; +let docsIndexOutDir: string; + +beforeAll(async () => { + // Build a real docs index from the repo's actual docs/public so the + // devicesdk_docs_search test below exercises the same content the tool + // ships with, without depending on `pnpm build` having run first. + docsIndexOutDir = mkdtempSync(join(tmpdir(), "dsdk-mcp-docs-index-")); + const indexPath = join(docsIndexOutDir, "docs-index.sqlite"); + const proc = Bun.spawnSync([ + "bun", + "run", + SCRIPT_PATH, + DOCS_PUBLIC_DIR, + indexPath, + ]); + if (proc.exitCode !== 0) { + throw new Error( + `docs index build failed (exit ${proc.exitCode}): ${proc.stderr.toString()}`, + ); + } + + srv = await TestServer.start({ DOCS_INDEX_PATH: indexPath }); +}); + +afterAll(async () => { + await srv.stop(); + rmSync(docsIndexOutDir, { recursive: true, force: true }); +}); + +describe("POST /mcp: authentication", () => { + test("no credentials -> 401 with WWW-Authenticate pointing at OAuth discovery", async () => { + const res = await srv.post("/mcp", { + body: rpc("tools/list", {}), + headers: MCP_HEADERS, + }); + expect(res.status).toBe(401); + const www = res.headers.get("www-authenticate") ?? ""; + expect(www).toContain("resource_metadata"); + expect(www).toContain("/.well-known/oauth-protected-resource"); + }); + + test("GET /mcp -> 405 (stateless server, no SSE stream to resume)", async () => { + const res = await srv.get("/mcp"); + expect(res.status).toBe(405); + }); + + test("DELETE /mcp -> 405 (no session to terminate)", async () => { + const res = await srv.delete("/mcp"); + expect(res.status).toBe(405); + }); +}); + +describe("POST /mcp: tools/list", () => { + let owner: TestUser; + beforeAll(async () => { + owner = await srv.register({ + email: `mcp-list-${crypto.randomUUID()}@example.com`, + }); + }); + + test("returns exactly the 15 devicesdk_* tools", async () => { + const res = await srv.post("/mcp", { + token: owner.token, + body: rpc("tools/list", {}), + headers: MCP_HEADERS, + }); + expect(res.status).toBe(200); + const body = res.body as McpEnvelope; + const names = (body.result?.tools ?? []).map((t) => t.name).sort(); + expect(names).toEqual(EXPECTED_TOOLS); + }); +}); + +describe("POST /mcp: tools/call", () => { + let owner: TestUser; + beforeAll(async () => { + owner = await srv.register({ + email: `mcp-call-${crypto.randomUUID()}@example.com`, + }); + }); + + test("devicesdk_whoami returns the caller's own account", async () => { + const res = await srv.post("/mcp", { + token: owner.token, + body: toolCall("devicesdk_whoami"), + headers: MCP_HEADERS, + }); + expect(res.status).toBe(200); + const parsed = parseToolBody(res.body as McpEnvelope) as { + success: boolean; + result: { email: string }; + }; + expect(parsed.success).toBe(true); + expect(parsed.result.email).toBe(owner.user.email); + }); + + test("devicesdk_list_projects returns a project created via REST", async () => { + const created = await srv.post("/v1/projects", { + token: owner.token, + body: { project_slug: "mcp-proj", name: "MCP Proj" }, + }); + expect(created.status).toBe(201); + + const res = await srv.post("/mcp", { + token: owner.token, + body: toolCall("devicesdk_list_projects"), + headers: MCP_HEADERS, + }); + expect(res.status).toBe(200); + const parsed = parseToolBody(res.body as McpEnvelope) as { + success: boolean; + result: { items: { project_slug: string }[] }; + }; + expect(parsed.success).toBe(true); + expect(parsed.result.items.some((p) => p.project_slug === "mcp-proj")).toBe( + true, + ); + }); + + test("devicesdk_env_set then devicesdk_env_list roundtrips a key", async () => { + await srv.post("/v1/projects", { + token: owner.token, + body: { project_slug: "mcp-env", name: "MCP Env" }, + }); + + const setRes = await srv.post("/mcp", { + token: owner.token, + body: toolCall("devicesdk_env_set", { + projectId: "mcp-env", + vars: { FOO: "bar" }, + }), + headers: MCP_HEADERS, + }); + expect(setRes.status).toBe(200); + expect((setRes.body as McpEnvelope).result?.isError).toBe( + false, + ); + + const listRes = await srv.post("/mcp", { + token: owner.token, + body: toolCall("devicesdk_env_list", { projectId: "mcp-env" }), + headers: MCP_HEADERS, + }); + const parsed = parseToolBody( + listRes.body as McpEnvelope, + ) as { success: boolean; result: { vars: { key: string }[] } }; + expect(parsed.success).toBe(true); + expect(parsed.result.vars.some((v) => v.key === "FOO")).toBe(true); + }); + + test("cross-user isolation: caller never sees another user's projects", async () => { + const userA = await srv.register({ + email: `mcp-a-${crypto.randomUUID()}@example.com`, + }); + const userB = await srv.register({ + email: `mcp-b-${crypto.randomUUID()}@example.com`, + }); + await srv.post("/v1/projects", { + token: userA.token, + body: { project_slug: "a-only", name: "A only" }, + }); + + const res = await srv.post("/mcp", { + token: userB.token, + body: toolCall("devicesdk_list_projects"), + headers: MCP_HEADERS, + }); + const parsed = parseToolBody(res.body as McpEnvelope) as { + result: { items: { project_slug: string }[] }; + }; + expect(parsed.result.items.some((p) => p.project_slug === "a-only")).toBe( + false, + ); + }); + + test("devicesdk_send_command against an unconnected device -> isError (503)", async () => { + await srv.post("/v1/projects", { + token: owner.token, + body: { project_slug: "mcp-cmd", name: "MCP Cmd" }, + }); + await srv.post("/v1/projects/mcp-cmd/devices", { + token: owner.token, + body: { device_id: "dev1", name: "Dev 1" }, + }); + + const res = await srv.post("/mcp", { + token: owner.token, + body: toolCall("devicesdk_send_command", { + projectId: "mcp-cmd", + deviceId: "dev1", + type: "reboot", + }), + headers: MCP_HEADERS, + }); + expect(res.status).toBe(200); + expect((res.body as McpEnvelope).result?.isError).toBe( + true, + ); + }); + + test("devicesdk_docs_search finds mDNS-related docs pages under /docs/", async () => { + const res = await srv.post("/mcp", { + token: owner.token, + body: toolCall("devicesdk_docs_search", { query: "mdns" }), + headers: MCP_HEADERS, + }); + expect(res.status).toBe(200); + const parsed = parseToolBody(res.body as McpEnvelope) as { + success: boolean; + result: { matches: { url: string }[] }; + }; + expect(parsed.success).toBe(true); + expect(parsed.result.matches.length).toBeGreaterThan(0); + expect( + parsed.result.matches.every((m) => + m.url.startsWith("https://devicesdk.com/docs/"), + ), + ).toBe(true); + }); +}); diff --git a/apps/server/tests/e2e/oauth.test.ts b/apps/server/tests/e2e/oauth.test.ts new file mode 100644 index 00000000..c5ed5629 --- /dev/null +++ b/apps/server/tests/e2e/oauth.test.ts @@ -0,0 +1,568 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { TestServer, type TestUser } from "../harness"; + +function form(fields: Record): string { + return new URLSearchParams(fields).toString(); +} + +function oauthCsrfCookie(headers: Headers): string { + const raw = headers.get("set-cookie") ?? ""; + const m = raw.match(/oauth_csrf=([^;]+)/); + if (!m) throw new Error(`no oauth_csrf cookie in: ${raw}`); + return decodeURIComponent(m[1]); +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function generatePkcePair(): Promise<{ + verifier: string; + challenge: string; +}> { + const verifierBytes = new Uint8Array(32); + crypto.getRandomValues(verifierBytes); + const verifier = base64UrlEncode(verifierBytes); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(verifier), + ); + const challenge = base64UrlEncode(new Uint8Array(digest)); + return { verifier, challenge }; +} + +interface RegisteredClient { + client_id: string; + client_name: string; + redirect_uris: string[]; +} + +async function registerClient( + srv: TestServer, + redirectUri = "http://localhost:9999/cb", + clientName = "test client", +): Promise { + const res = await srv.post("/oauth/register", { + body: { client_name: clientName, redirect_uris: [redirectUri] }, + }); + if (res.status !== 201) { + throw new Error(`register client failed: ${res.status} ${res.text}`); + } + return res.body as RegisteredClient; +} + +/** + * Drives GET /oauth/authorize (consent page + CSRF cookie) then POST + * /oauth/authorize (approve/deny). Returns the final response so callers can + * inspect status/Location without this helper hiding failures. + */ +async function driveAuthorize( + srv: TestServer, + token: string, + params: { + clientId: string; + redirectUri: string; + codeChallenge: string; + state?: string; + }, + action: "approve" | "deny" = "approve", +): Promise<{ status: number; location: string | null; bodyText: string }> { + const query: Record = { + response_type: "code", + client_id: params.clientId, + redirect_uri: params.redirectUri, + code_challenge: params.codeChallenge, + code_challenge_method: "S256", + }; + if (params.state !== undefined) query.state = params.state; + + const page = await srv.get("/oauth/authorize", { token, query }); + if (page.status !== 200) { + return { status: page.status, location: null, bodyText: page.text }; + } + const csrf = oauthCsrfCookie(page.headers); + + const post = await srv.post("/oauth/authorize", { + token, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Cookie: `oauth_csrf=${csrf}`, + }, + rawBody: form({ ...query, csrf_token: csrf, action }), + }); + return { + status: post.status, + location: post.headers.get("location"), + bodyText: post.text, + }; +} + +function codeFromLocation(location: string | null): string { + if (!location) throw new Error("no Location header"); + const url = new URL(location); + const code = url.searchParams.get("code"); + if (!code) throw new Error(`no code in Location: ${location}`); + return code; +} + +interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +async function exchangeCode( + srv: TestServer, + params: { + code: string; + redirectUri: string; + clientId: string; + codeVerifier: string; + }, +) { + return srv.post("/oauth/token", { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + rawBody: form({ + grant_type: "authorization_code", + code: params.code, + redirect_uri: params.redirectUri, + client_id: params.clientId, + code_verifier: params.codeVerifier, + }), + }); +} + +describe("oauth: discovery metadata", () => { + let srv: TestServer; + beforeAll(async () => { + srv = await TestServer.start(); + }); + afterAll(() => srv.stop()); + + test("authorization-server metadata: issuer matches the request origin", async () => { + const res = await srv.get("/.well-known/oauth-authorization-server"); + expect(res.status).toBe(200); + const body = res.body as Record; + expect(body.issuer).toBe(srv.baseUrl); + expect(body.authorization_endpoint).toBe(`${srv.baseUrl}/oauth/authorize`); + expect(body.token_endpoint).toBe(`${srv.baseUrl}/oauth/token`); + expect(body.registration_endpoint).toBe(`${srv.baseUrl}/oauth/register`); + expect(body.code_challenge_methods_supported).toEqual(["S256"]); + expect(body.token_endpoint_auth_methods_supported).toEqual(["none"]); + }); + + test("protected-resource metadata points at /mcp on this origin", async () => { + const res = await srv.get("/.well-known/oauth-protected-resource"); + expect(res.status).toBe(200); + const body = res.body as Record; + expect(body.resource).toBe(`${srv.baseUrl}/mcp`); + expect(body.authorization_servers).toEqual([srv.baseUrl]); + }); + + test("protected-resource metadata is also served at the /mcp-suffixed path", async () => { + const res = await srv.get("/.well-known/oauth-protected-resource/mcp"); + expect(res.status).toBe(200); + expect((res.body as Record).resource).toBe( + `${srv.baseUrl}/mcp`, + ); + }); +}); + +describe("oauth: dynamic client registration", () => { + let srv: TestServer; + beforeAll(async () => { + srv = await TestServer.start(); + }); + afterAll(() => srv.stop()); + + test("happy path: 201 with a client_id and echoed metadata", async () => { + const res = await srv.post("/oauth/register", { + body: { + client_name: "curl test", + redirect_uris: ["http://localhost:9999/cb"], + }, + }); + expect(res.status).toBe(201); + const body = res.body as RegisteredClient & { + token_endpoint_auth_method: string; + }; + expect(typeof body.client_id).toBe("string"); + expect(body.client_name).toBe("curl test"); + expect(body.redirect_uris).toEqual(["http://localhost:9999/cb"]); + expect(body.token_endpoint_auth_method).toBe("none"); + }); + + test("bad redirect_uri -> 400 invalid_client_metadata", async () => { + const res = await srv.post("/oauth/register", { + body: { client_name: "bad", redirect_uris: ["notaurl"] }, + }); + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toBe( + "invalid_client_metadata", + ); + }); + + test("missing redirect_uris -> 400 invalid_client_metadata", async () => { + const res = await srv.post("/oauth/register", { + body: { client_name: "no redirects" }, + }); + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toBe( + "invalid_client_metadata", + ); + }); + + test("default client_name is applied when omitted", async () => { + const res = await srv.post("/oauth/register", { + body: { redirect_uris: ["https://example.com/cb"] }, + }); + expect(res.status).toBe(201); + expect((res.body as RegisteredClient).client_name).toBe("MCP client"); + }); +}); + +describe("oauth: full authorization code + PKCE flow", () => { + let srv: TestServer; + let user: TestUser; + let client: RegisteredClient; + const redirectUri = "http://localhost:9999/cb"; + + beforeAll(async () => { + srv = await TestServer.start(); + user = await srv.register({ + email: `oauth-flow-${crypto.randomUUID()}@example.com`, + }); + client = await registerClient(srv, redirectUri, "flow client"); + }); + afterAll(() => srv.stop()); + + test("approve -> redirect with code + state -> token exchange -> access token works on /mcp", async () => { + const { verifier, challenge } = await generatePkcePair(); + const auth = await driveAuthorize(srv, user.token, { + clientId: client.client_id, + redirectUri, + codeChallenge: challenge, + state: "xyz123", + }); + expect(auth.status).toBe(302); + const location = new URL(auth.location ?? ""); + expect(location.origin + location.pathname).toBe(redirectUri); + expect(location.searchParams.get("state")).toBe("xyz123"); + const code = codeFromLocation(auth.location); + + const tokenRes = await exchangeCode(srv, { + code, + redirectUri, + clientId: client.client_id, + codeVerifier: verifier, + }); + expect(tokenRes.status).toBe(200); + const tokenBody = tokenRes.body as TokenResponse; + expect(tokenBody.token_type).toBe("Bearer"); + expect(tokenBody.expires_in).toBe(2592000); + expect(typeof tokenBody.access_token).toBe("string"); + + const mcpRes = await srv.post("/mcp", { + token: tokenBody.access_token, + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "devicesdk_whoami", arguments: {} }, + }, + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + }); + expect(mcpRes.status).toBe(200); + }); + + test("code is single-use: reusing it -> invalid_grant", async () => { + const { verifier, challenge } = await generatePkcePair(); + const auth = await driveAuthorize(srv, user.token, { + clientId: client.client_id, + redirectUri, + codeChallenge: challenge, + }); + const code = codeFromLocation(auth.location); + + const first = await exchangeCode(srv, { + code, + redirectUri, + clientId: client.client_id, + codeVerifier: verifier, + }); + expect(first.status).toBe(200); + + const second = await exchangeCode(srv, { + code, + redirectUri, + clientId: client.client_id, + codeVerifier: verifier, + }); + expect(second.status).toBe(400); + expect((second.body as { error: string }).error).toBe("invalid_grant"); + }); + + test("expired code -> invalid_grant", async () => { + const { verifier, challenge } = await generatePkcePair(); + const auth = await driveAuthorize(srv, user.token, { + clientId: client.client_id, + redirectUri, + codeChallenge: challenge, + }); + const code = codeFromLocation(auth.location); + + // Force-expire the just-issued code directly in the test DB (the + // harness exposes the raw bun:sqlite handle for exactly this). + const row = srv.db + .query( + "SELECT id FROM oauth_auth_codes WHERE client_id = ?1 ORDER BY created_at DESC LIMIT 1", + ) + .get(client.client_id) as { id: string }; + srv.db.run("UPDATE oauth_auth_codes SET expires_at = ?1 WHERE id = ?2", [ + Date.now() - 1000, + row.id, + ]); + + const res = await exchangeCode(srv, { + code, + redirectUri, + clientId: client.client_id, + codeVerifier: verifier, + }); + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toBe("invalid_grant"); + }); + + test("wrong code_verifier -> invalid_grant", async () => { + const { challenge } = await generatePkcePair(); + const auth = await driveAuthorize(srv, user.token, { + clientId: client.client_id, + redirectUri, + codeChallenge: challenge, + }); + const code = codeFromLocation(auth.location); + + const res = await exchangeCode(srv, { + code, + redirectUri, + clientId: client.client_id, + codeVerifier: "totally-the-wrong-verifier-value-12345", + }); + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toBe("invalid_grant"); + }); + + test("mismatched redirect_uri at token exchange -> invalid_grant", async () => { + const { verifier, challenge } = await generatePkcePair(); + const auth = await driveAuthorize(srv, user.token, { + clientId: client.client_id, + redirectUri, + codeChallenge: challenge, + }); + const code = codeFromLocation(auth.location); + + const res = await exchangeCode(srv, { + code, + redirectUri: "http://localhost:9999/DIFFERENT", + clientId: client.client_id, + codeVerifier: verifier, + }); + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toBe("invalid_grant"); + }); + + test("deny -> redirect with error=access_denied, no code minted", async () => { + const { challenge } = await generatePkcePair(); + const auth = await driveAuthorize( + srv, + user.token, + { + clientId: client.client_id, + redirectUri, + codeChallenge: challenge, + state: "denystate", + }, + "deny", + ); + expect(auth.status).toBe(302); + const location = new URL(auth.location ?? ""); + expect(location.searchParams.get("error")).toBe("access_denied"); + expect(location.searchParams.get("state")).toBe("denystate"); + expect(location.searchParams.has("code")).toBe(false); + }); + + test("unknown client_id or unregistered redirect_uri renders an error page, not a redirect", async () => { + const res = await srv.get("/oauth/authorize", { + token: user.token, + query: { + response_type: "code", + client_id: "00000000-0000-0000-0000-000000000000", + redirect_uri: redirectUri, + code_challenge: "x".repeat(43), + code_challenge_method: "S256", + }, + }); + expect(res.status).toBe(400); + expect(res.headers.get("location")).toBeNull(); + }); + + test("GET /oauth/authorize unauthenticated redirects to login", async () => { + const res = await srv.get("/oauth/authorize", { + query: { + response_type: "code", + client_id: client.client_id, + redirect_uri: redirectUri, + code_challenge: "x".repeat(43), + code_challenge_method: "S256", + }, + }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/login"); + }); +}); + +describe("oauth: minted token lifecycle", () => { + let srv: TestServer; + let user: TestUser; + let client: RegisteredClient; + const redirectUri = "http://localhost:9999/cb"; + + beforeAll(async () => { + srv = await TestServer.start(); + user = await srv.register({ + email: `oauth-lifecycle-${crypto.randomUUID()}@example.com`, + }); + client = await registerClient(srv, redirectUri, "lifecycle client"); + }); + afterAll(() => srv.stop()); + + test("minted token is listed in /v1/tokens as managed with an MCP description, and revocation locks out /mcp", async () => { + const { verifier, challenge } = await generatePkcePair(); + const auth = await driveAuthorize(srv, user.token, { + clientId: client.client_id, + redirectUri, + codeChallenge: challenge, + }); + const code = codeFromLocation(auth.location); + const tokenRes = await exchangeCode(srv, { + code, + redirectUri, + clientId: client.client_id, + codeVerifier: verifier, + }); + const accessToken = (tokenRes.body as TokenResponse).access_token; + + const list = await srv.get("/v1/tokens", { token: user.token }); + const items = ( + list.body as { + result: { + items: { id: string; managed?: boolean; description?: string }[]; + }; + } + ).result.items; + const mine = items.find((t) => t.description === "MCP: lifecycle client"); + expect(mine).toBeDefined(); + expect(mine?.managed).toBe(true); + + // The token authenticates /mcp before revocation. + const before = await srv.post("/mcp", { + token: accessToken, + body: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + }); + expect(before.status).toBe(200); + + const del = await srv.delete(`/v1/tokens/${mine?.id}`, { + token: user.token, + }); + expect(del.status).toBe(200); + + const after = await srv.post("/mcp", { + token: accessToken, + body: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + }); + expect(after.status).toBe(401); + }); +}); + +describe("oauth: token expiry regression (authenticateUser WHERE change)", () => { + let srv: TestServer; + let user: TestUser; + let client: RegisteredClient; + const redirectUri = "http://localhost:9999/cb"; + + beforeAll(async () => { + srv = await TestServer.start(); + user = await srv.register({ + email: `oauth-expiry-${crypto.randomUUID()}@example.com`, + }); + client = await registerClient(srv, redirectUri, "expiry client"); + }); + afterAll(() => srv.stop()); + + test("an OAuth-minted token stops working once its expires_at is in the past", async () => { + const { verifier, challenge } = await generatePkcePair(); + const auth = await driveAuthorize(srv, user.token, { + clientId: client.client_id, + redirectUri, + codeChallenge: challenge, + }); + const code = codeFromLocation(auth.location); + const tokenRes = await exchangeCode(srv, { + code, + redirectUri, + clientId: client.client_id, + codeVerifier: verifier, + }); + const accessToken = (tokenRes.body as TokenResponse).access_token; + + // Sanity: works before we force-expire it. + const before = await srv.get("/v1/projects", { token: accessToken }); + expect(before.status).toBe(200); + + srv.db.run( + "UPDATE tokens SET expires_at = ?1 WHERE token_hash IN (SELECT token_hash FROM tokens WHERE user_id = ?2 ORDER BY created_at DESC LIMIT 1)", + [Date.now() - 1000, user.user.id], + ); + + const afterRest = await srv.get("/v1/projects", { token: accessToken }); + expect(afterRest.status).toBe(401); + + const afterMcp = await srv.post("/mcp", { + token: accessToken, + body: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + }); + expect(afterMcp.status).toBe(401); + }); + + test("a NULL-expiry token (created via POST /v1/tokens) keeps working - no regression for existing tokens", async () => { + const created = await srv.post("/v1/tokens", { + token: user.token, + body: { description: "never expires" }, + }); + const rawToken = (created.body as { result: { token: string } }).result + .token; + + const res = await srv.get("/v1/projects", { token: rawToken }); + expect(res.status).toBe(200); + }); +}); diff --git a/apps/server/tests/unit/docs-index.test.ts b/apps/server/tests/unit/docs-index.test.ts new file mode 100644 index 00000000..372d0383 --- /dev/null +++ b/apps/server/tests/unit/docs-index.test.ts @@ -0,0 +1,172 @@ +import { Database } from "bun:sqlite"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resetDocsSearchCache, searchDocs } from "../../src/mcp/docsSearch"; + +const SCRIPT_PATH = new URL( + "../../scripts/build-docs-index.ts", + import.meta.url, +).pathname; + +let fixtureDir: string; +let indexPath: string; + +beforeAll(() => { + fixtureDir = mkdtempSync(join(tmpdir(), "dsdk-docs-fixture-")); + mkdirSync(join(fixtureDir, "sub"), { recursive: true }); + + // Densely about "banana" - should outrank page-b for a "banana" query + // under BM25 (higher term frequency, shorter/more focused document). + writeFileSync( + join(fixtureDir, "page-a.md"), + `--- +title: Banana Guide +description: Everything about bananas +--- + +Bananas are a tropical fruit. This banana guide covers banana varieties, +banana nutrition, and banana recipes. Bananas are rich in potassium. +`, + ); + + // Mentions "banana" only twice, tangentially, in a longer apple-focused doc. + writeFileSync( + join(fixtureDir, "sub", "page-b.md"), + `--- +title: Apple Guide +description: Everything about apples +--- + +Apples are a temperate fruit. This guide covers apple varieties and apple +pie recipes, growing apples, and storing apples over winter. Have you ever +seen a banana growing on an apple tree? No - apples and bananas don't mix, +they need very different climates entirely. +`, + ); + + writeFileSync( + join(fixtureDir, "_index.md"), + `--- +title: Fruit Docs +description: Fruit documentation index +--- + +Welcome to the fruit documentation. See the guides for apples and bananas. +`, + ); + + const outDir = mkdtempSync(join(tmpdir(), "dsdk-docs-index-")); + indexPath = join(outDir, "docs-index.sqlite"); + + const proc = Bun.spawnSync([ + "bun", + "run", + SCRIPT_PATH, + fixtureDir, + indexPath, + ]); + if (proc.exitCode !== 0) { + throw new Error( + `indexer failed (exit ${proc.exitCode}): ${proc.stderr.toString()}`, + ); + } + + // Point the tool under test (devicesdk_docs_search) at this fixture index. + // resetDocsSearchCache() guarantees this test file sees its own fixture + // regardless of whatever a previous test (in this file or, if bun:test + // ever shares a module registry across files, another one) already cached. + process.env.DOCS_INDEX_PATH = indexPath; + resetDocsSearchCache(); +}); + +afterAll(() => { + resetDocsSearchCache(); + rmSync(fixtureDir, { recursive: true, force: true }); + rmSync(join(indexPath, ".."), { recursive: true, force: true }); +}); + +describe("build-docs-index: indexing", () => { + test("indexes exactly the 3 fixture pages", () => { + const db = new Database(indexPath, { readonly: true }); + const meta = db.query("SELECT doc_count FROM meta").get() as { + doc_count: number; + }; + expect(meta.doc_count).toBe(3); + db.close(); + }); + + test("path mapping: dir/page.md -> /docs/dir/page/, _index.md -> /docs/", () => { + const db = new Database(indexPath, { readonly: true }); + const rows = db.query("SELECT path FROM docs_fts ORDER BY path").all() as { + path: string; + }[]; + const paths = rows.map((r) => r.path).sort(); + expect(paths).toEqual(["/docs/", "/docs/page-a/", "/docs/sub/page-b/"]); + db.close(); + }); + + test("title/description are carried through from frontmatter", () => { + const db = new Database(indexPath, { readonly: true }); + const row = db + .query("SELECT title, description FROM docs_fts WHERE path = ?1") + .get("/docs/page-a/") as { title: string; description: string }; + expect(row.title).toBe("Banana Guide"); + expect(row.description).toBe("Everything about bananas"); + db.close(); + }); +}); + +describe("devicesdk_docs_search (searchDocs) against the fixture index", () => { + test("BM25 ranks the banana-focused page above the apple-focused page", () => { + const result = searchDocs("banana"); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.result.matches.length).toBeGreaterThan(0); + expect(result.result.matches[0].path).toBe("/docs/page-a/"); + }); + + test("returns a snippet and a devicesdk.com URL for each match", () => { + const result = searchDocs("banana"); + expect(result.success).toBe(true); + if (!result.success) return; + const first = result.result.matches[0]; + expect(first.url).toBe("https://devicesdk.com/docs/page-a/"); + expect(typeof first.snippet).toBe("string"); + expect(first.snippet.length).toBeGreaterThan(0); + }); + + test("a query full of FTS5 operator syntax never throws - result set or empty", () => { + expect(() => searchDocs('AND ( * NEAR "unterminated')).not.toThrow(); + const result = searchDocs('AND ( * NEAR "unterminated'); + expect(result.success).toBe(true); + if (result.success) { + expect(Array.isArray(result.result.matches)).toBe(true); + } + }); + + test("empty query is a structured failure, not a throw", () => { + const result = searchDocs(" "); + expect(result.success).toBe(false); + }); +}); + +describe("devicesdk_docs_search: missing index file", () => { + afterAll(() => { + // Restore the fixture index for any test file sharing this process. + process.env.DOCS_INDEX_PATH = indexPath; + resetDocsSearchCache(); + }); + + test("reports docs_index_missing instead of throwing", () => { + process.env.DOCS_INDEX_PATH = join(fixtureDir, "does-not-exist.sqlite"); + resetDocsSearchCache(); + + const result = searchDocs("banana"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe("docs_index_missing"); + } + }); +}); From 7088292c626c8824c88b13a1a5884855bab093d0 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:04:15 +0000 Subject: [PATCH 06/16] feat(cli): scaffold http .mcp.json pointing at the server's /mcp endpoint devicesdk init no longer preconfigures the npx @devicesdk/mcp stdio server (that package is being removed - the server now bundles MCP itself at /mcp). generateMcpJson() is now async and reuses the host already resolved by createProject() earlier in the same init() call (getApiUrl(), which caches env var / --host / ~/.devicesdk/credentials.json resolution), falling back to the default mDNS hostname (http://devicesdk.local:8080) if that somehow comes back empty. Test changes: mock getApiUrl() in init.test.ts (the real one does mDNS discovery + process.exit(1) with no host configured - never let a unit test touch that) and add coverage for the new .mcp.json shape, the fallback host, and not overwriting an existing .mcp.json. Part of plan 003 (bundled MCP server + OAuth 2.1). --- packages/cli/src/commands/init.test.ts | 57 ++++++++++++++++++++++++++ packages/cli/src/commands/init.ts | 22 +++++++--- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 2a528682..3c8badea 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -9,6 +9,7 @@ vi.mock("../credentials.js", () => ({ const apiMocks = { createProject: vi.fn(), + getApiUrl: vi.fn(), }; vi.mock("../api.js", async (importOriginal) => { @@ -17,6 +18,11 @@ vi.mock("../api.js", async (importOriginal) => { ...original, createProject: (...args: Parameters) => apiMocks.createProject(...args), + // Real getApiUrl() does mDNS discovery and process.exit(1) when no host + // is configured - never let a unit test touch that. init() only calls + // it after createProject() already succeeded, so a resolved host is + // always the realistic case here. + getApiUrl: () => apiMocks.getApiUrl(), }; }); @@ -39,6 +45,7 @@ describe("init command", () => { beforeEach(() => { vi.clearAllMocks(); apiMocks.createProject.mockResolvedValue({}); + apiMocks.getApiUrl.mockResolvedValue("http://localhost:8080"); execaMock.mockResolvedValue({}); // Default: all files don't exist (access throws ENOENT) accessSpy.mockRejectedValue( @@ -168,4 +175,54 @@ describe("init command", () => { expect(written.some((p) => p.includes("src/devices"))).toBe(false); expect(exitSpy).not.toHaveBeenCalled(); }); + + it("generates .mcp.json pointing at the server's bundled /mcp endpoint (HTTP, not npx)", async () => { + apiMocks.getApiUrl.mockResolvedValue("http://devicesdk.example:8080"); + + await init(); + + const call = writeFileSpy.mock.calls.find(([p]) => + String(p).endsWith(".mcp.json"), + ); + expect(call).toBeDefined(); + const content = JSON.parse(String(call?.[1])); + expect(content).toEqual({ + mcpServers: { + devicesdk: { + type: "http", + url: "http://devicesdk.example:8080/mcp", + }, + }, + }); + // No more npx/@devicesdk/mcp - that package is removed. + expect(String(call?.[1])).not.toContain("@devicesdk/mcp"); + expect(String(call?.[1])).not.toContain("npx"); + }); + + it("falls back to the default mDNS hostname if getApiUrl() resolves empty", async () => { + apiMocks.getApiUrl.mockResolvedValue(""); + + await init(); + + const call = writeFileSpy.mock.calls.find(([p]) => + String(p).endsWith(".mcp.json"), + ); + const content = JSON.parse(String(call?.[1])); + expect(content.mcpServers.devicesdk.url).toBe( + "http://devicesdk.local:8080/mcp", + ); + }); + + it("does not overwrite an existing .mcp.json", async () => { + accessSpy.mockImplementation(async (p: Parameters[0]) => { + if (String(p).endsWith(".mcp.json")) return undefined; // already exists + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await init(); + + const written = writeFileSpy.mock.calls.map(([p]) => String(p)); + expect(written.some((p) => p.endsWith(".mcp.json"))).toBe(false); + expect(exitSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 09330390..6997ba06 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; import { execa } from "execa"; -import { createProject, DeviceSDKApiError } from "../api.js"; +import { createProject, DeviceSDKApiError, getApiUrl } from "../api.js"; import { requireAuth } from "../credentials.js"; import { EXIT } from "../exitCodes.js"; @@ -377,13 +377,23 @@ alwaysApply: true `; } -function generateMcpJson(): string { +/** + * Scaffolds .mcp.json pointing at this server's own bundled /mcp endpoint + * (Streamable HTTP - no npm package to install, no separate process). By the + * time this is called in init(), createProject() has already resolved and + * cached a host via getApiUrl() (env var, --host flag, or + * ~/.devicesdk/credentials.json), so this reuses that resolution instead of + * re-triggering mDNS discovery. Falls back to the default mDNS hostname if, + * for any reason, no host could be determined. + */ +async function generateMcpJson(): Promise { + const host = (await getApiUrl()) || "http://devicesdk.local:8080"; return JSON.stringify( { mcpServers: { devicesdk: { - command: "npx", - args: ["-y", "@devicesdk/mcp"], + type: "http", + url: `${host}/mcp`, }, }, }, @@ -529,12 +539,12 @@ export default async function init( } // Generate .mcp.json so users with MCP-aware agents (OpenCode, Claude - // Code, Cursor) get the @devicesdk/mcp server preconfigured. + // Code, Cursor) get this server's bundled /mcp endpoint preconfigured. const mcpJsonPath = path.join(projectDir, ".mcp.json"); try { await fs.access(mcpJsonPath); } catch { - await fs.writeFile(mcpJsonPath, generateMcpJson()); + await fs.writeFile(mcpJsonPath, await generateMcpJson()); console.log("✓ Generated .mcp.json"); } From 1c9477d858b93c69e2254b1b12430380ad4a8c80 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:09:15 +0000 Subject: [PATCH 07/16] feat!: remove the @devicesdk/mcp npm package The server now bundles MCP itself at /mcp (previous commits); the npm stdio package that shelled out to the CLI has no users to migrate (owner confirmed) and is removed outright rather than deprecated in place. - git rm -r packages/mcp, pnpm-lock.yaml updated. - AGENTS.md, README.md, CONTRIBUTING.md: drop the packages/mcp table rows and "CLI/MCP run on plain Node" phrasing (MCP is now part of the Bun server); add apps/server/src/mcp/ + apps/server/src/oauth/ to AGENTS.md's Source-of-Truth table and a Server-architecture bullet describing the MCP/OAuth subsystems, matching how every other subsystem there is documented. - docs/public/cli/init.md: the two .mcp.json mentions now describe the bundled-server / HTTP form instead of the npx package. Historical CHANGELOG.md entries and docs/public/changelog.md's past-release notes are left untouched (they describe what shipped at the time). Unpublishing/deprecating the already-published npm versions needs npm credentials a human has to run - not attempted here. docs/public/mcp.md itself is rewritten in the next commit. BREAKING CHANGE: @devicesdk/mcp is no longer published from this repo. Existing installs of the npm package keep working against whatever server they're pointed at (it's a thin CLI wrapper) but will not receive updates. Part of plan 003 (bundled MCP server + OAuth 2.1). --- packages/mcp/CHANGELOG.md | 166 ---------- packages/mcp/LICENSE | 661 ------------------------------------- packages/mcp/README.md | 52 --- packages/mcp/package.json | 55 --- packages/mcp/src/index.ts | 301 ----------------- packages/mcp/tsconfig.json | 13 - 6 files changed, 1248 deletions(-) delete mode 100644 packages/mcp/CHANGELOG.md delete mode 100644 packages/mcp/LICENSE delete mode 100644 packages/mcp/README.md delete mode 100644 packages/mcp/package.json delete mode 100644 packages/mcp/src/index.ts delete mode 100644 packages/mcp/tsconfig.json diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md deleted file mode 100644 index 1ee749f3..00000000 --- a/packages/mcp/CHANGELOG.md +++ /dev/null @@ -1,166 +0,0 @@ -# @devicesdk/mcp - -## 0.2.6 - -### Patch Changes - -- Updated dependencies [ee33066] - - @devicesdk/cli@0.7.2 - -## 0.2.5 - -### Patch Changes - -- 79dcc96: Update all GitHub repo and Docker image references from `device-sdk/devicesdk-monorepo` to `device-sdk/devicesdk` following the GitHub repository rename. -- Updated dependencies [2905d82] -- Updated dependencies [79dcc96] - - @devicesdk/cli@0.7.1 - -## 0.2.4 - -### Patch Changes - -- e299282: Baseline community, security, and licensing cleanup: - - Added `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, and `SECURITY.md`. - - Fixed root `README.md` tech-stack copy (website is Vue 3 + Vite SSG, not Hugo). - - Replaced remaining `CLAUDE.md` references with `AGENTS.md` across docs and firmware readmes. - - Updated `firmware/pico/IMPLEMENTATIONS.md` and `src/ca_cert.h` comments for the self-hosted era. - - Added the AGPL-3.0-only license to every workspace `package.json` and copied `LICENSE` into `packages/core`, `packages/cli`, `packages/mcp`, and `packages/typescript-config`. - - Excluded `examples/*` from the root `pnpm build` to avoid CLI-dependent example builds in the default task. - - Removed `apps/server/openapi.json` from git, gitignored the generated file, and updated website-deploy triggers to rebuild it from server sources. - - Hardened Docker defaults: `ALLOW_REGISTRATION=false`, `SECURE_COOKIES=true`, non-root runtime user, and a `/health` `HEALTHCHECK`. - - Added GitHub issue/PR templates and `CODEOWNERS`. - - Scoped Device WebSocket `versionId` lookup to the device (`device_id` filter). - - Scoped CLI token revocation to the authenticated user (`user_id` filter). - -- Updated dependencies [e299282] -- Updated dependencies [0ec78d4] - - @devicesdk/cli@0.7.0 - -## 0.2.3 - -### Patch Changes - -- 874cd73: Follow-up docs cleanup: fix stale cloud-era references that survived the - self-host pivot. - - **Public docs (`docs/public/`)**: corrected `troubleshooting.md` (dropped - "edge script/edge location", Cloudflare-era queues, the dead - `status.devicesdk.com` status page, the hardcoded port-443 firewall note, and - the request-quota framing - the server only rate-limits auth brute-force); - fixed `concepts/env-vars.md` (`DeviceSender` → `DeviceEntrypoint` + import), - `guides/home-assistant.md` (`defineConfig` import from `@devicesdk/cli`, repo - URL), `cli/init.md` (documented the real `--no-git` flag, removed the - non-existent `--name`), `cli/deploy.md` (removed the non-existent - `deploy --version`), `hardware/esp32-c61.md` (`devicesdk-client.bin` → - `esp32c61-client.bin`), broken `github.com/device-sdk` org-root links, and a - stray `` artifact at the end of `resources/faq.md`. Trimmed - the obsolete Cloudflare/Durable-Object/OAuth "Platform Roadmap" section from - the (unpublished) `docs/public/ROADMAP.md`. - - **Marketing site (`apps/website`)**: removed the dead cloud-billing model from - the Solutions page ("Estimated cost / Free tier / ~$0.60/month / daily limit" - → "Self-hosted"); fixed `export default class` hero/product code samples to - the required named `export class`; "cloud KV" → "device KV"; rewrote the - website `README.md` (it still described the old pure-HTML/jQuery/Wrangler - setup - it's a Hugo + Tailwind site now, still deployed to Cloudflare Pages). - Also pointed every "GitHub" link (the `githubUrl` param, nav/footer menus, - footer license link, about page, terms/privacy) at the repo - (`device-sdk/devicesdk`) instead of the bare org root, and aligned a - "KV namespace" → "KV store" code comment with the rest of the self-host copy. - - **Package READMEs**: `@devicesdk/core` ("sandboxed serverless runtime" → - in-process on the self-hosted server), `@devicesdk/cli` (`login` now needs - `--host`), `@devicesdk/mcp` (`auth.json` → `credentials.json`). - - **Firmware (`firmware/pico/README.md`)**: rewrote the stale "devicesdk-client" - README (cloud backend, port 8787, personal absolute paths) and scrubbed the - committed Wi-Fi credentials / API tokens it documented. Docs only - no - firmware behavior change. - - **Firmware (`firmware/esp32/`)**: rewrote the bare ESP-IDF "Hello World" - `README.md` into a real DeviceSDK ESP32 firmware doc, rewrote the - Pico-porting-guide `IMPLEMENTATIONS.md` into an accurate ESP32 architecture - reference, deleted the redundant `PROJECT_SUMMARY.md` (leaked personal path + - wrong CC0 license claim), and dropped the obsolete Cloudflare Durable-Object - billing rationale from a `config.h` comment. Docs/comment only. - -- 6d0a71b: DeviceSDK is now a self-hosted, open-source platform. The Cloudflare-hosted - backend (`apps/api`) is replaced by `@devicesdk/server`, a single Bun process - (Hono + bun:sqlite + filesystem storage) that serves the REST API, device and - watcher WebSockets, and the dashboard UI on one port, distributed as a Docker - image (amd64 + arm64). - - Server: in-process device runtime replaces Durable Objects (same watch - protocol, command acks, connection-gated crons, per-device KV, inter-device - RPC); local email/password accounts replace Google OAuth; usage metrics in - SQLite replace Analytics Engine; plans/tiers/daily message limits removed. - - Dashboard: local login/registration with first-run setup; served - same-origin by the server; cost/billing UI removed. - - CLI: no default cloud endpoint - connect with `devicesdk login --host -http://:8080` (stored in credentials) or `DEVICESDK_API_URL`. - - Firmware: Pico gains plain `ws://` support when the host has an explicit - port (ESP32 already had it); binaries now publish to rolling GitHub - Releases instead of R2. - - License: AGPL-3.0-only. - -- Updated dependencies [874cd73] -- Updated dependencies [f724c46] -- Updated dependencies [3a72934] -- Updated dependencies [6d0a71b] - - @devicesdk/cli@0.6.0 - -## 0.2.2 - -### Patch Changes - -- Updated dependencies [0334095] - - @devicesdk/cli@0.5.2 - -## 0.2.1 - -### Patch Changes - -- Updated dependencies [660920d] - - @devicesdk/cli@0.5.1 - -## 0.2.0 - -### Minor Changes - -- d03b5ae: Major AI-agent-friendliness pass across the SDK so users' coding agents (Claude, Cursor, Copilot, Aider, etc.) can work in DeviceSDK projects with the right context on the first try. - - **`@devicesdk/core`** - additive only: - - Ships `AGENTS.md` inside the npm tarball (`node_modules/@devicesdk/core/AGENTS.md`) - version-matched API guidance for agents. - - Ships the full `docs/` folder (guides, examples) inside the tarball. - - JSDoc with runnable `@example` blocks added to every method on `DeviceSenderInterface`. Lifecycle hooks on `DeviceEntrypoint` (`onDeviceConnect`, `onDeviceDisconnect`, `onMessage`, `onCron`) now carry block-comment JSDoc that survives into the `.d.ts`. - - New: branded ID types (`ProjectId`, `DeviceId`, `ScriptId`, `TokenId`) plus boundary constructors (`asProjectId`, `asDeviceId`, …) for nominal-style ID safety. - - New: `OnboardLED = 99` constant for portable LED code across Pico W, Pico 2 W, ESP32-C3, ESP32-C61. - - New: literal pin unions in `@devicesdk/core/devices/pico` (`PicoGpioPin`, `PicoAdcPin`, `PicoPwmPin`) and a new subpath `@devicesdk/core/devices/esp32` (`Esp32GpioPin`, `Esp32C3GpioPin`, `Esp32C61GpioPin`, etc.). - - Expanded npm `keywords` and pointed `homepage` at `/docs/`. - - Fixed: README hello-world snippet referenced a nonexistent `this.ctx.device.log` API; now uses `console.log` and properly types `onMessage(message: DeviceResponse)`. - - **`@devicesdk/cli`** - additive only: - - `devicesdk init` now scaffolds `AGENTS.md`, `CLAUDE.md` (one-line `@AGENTS.md`), `.cursor/rules/devicesdk.mdc`, `.mcp.json` (preconfigured for `@devicesdk/mcp`), and a project `README.md`. - - `devicesdk init` no longer scaffolds `onMessage(message: any)` - templates use `onMessage(message: DeviceResponse)` and demonstrate inter-device RPC. - - `devicesdk build` now emits `import type { UserWorkerEnv }` instead of the deprecated `GetEnv` alias in `devicesdk-env.d.ts`. - - New `--json` flag on `whoami`, `status`, `logs`, `env list` (output `{success, result|error}`). `logs --tail --json` emits NDJSON. `DEVICESDK_OUTPUT=json` works as a global toggle. - - `DeviceSDKApiError` now carries an optional `docs` URL alongside the existing `code`. `parseErrorBody` extracts both. Added `invalid_cli_token` and `missing_credentials` to the auth-expired set so the CLI surfaces the right "run `devicesdk login`" hint. - - Help text gained "More: " footers. - - **`@devicesdk/mcp`** - new package: - - `npx -y @devicesdk/mcp` runs an MCP stdio server exposing 7 tools to coding agents: `devicesdk_whoami`, `devicesdk_status`, `devicesdk_logs_tail`, `devicesdk_env_list`, `devicesdk_env_set`, `devicesdk_deploy`, `devicesdk_docs_search`. - - Each tool wraps the equivalent `devicesdk --json` invocation; auth is inherited from the CLI's `~/.devicesdk/auth.json`. - - **`@devicesdk/api`** - additive only: - - `apps/api/src/foundation/auth.ts` now returns differentiated, machine-readable error codes (`missing_credentials`, `invalid_token`, `invalid_cli_token`, `account_suspended`, `account_deletion_pending`) and a `docs` URL pointing at the new `/docs/errors//` pages, in place of the previous catch-all `"Authentication error"` string. - - `DeviceSender` (`apps/api/src/durableObjects/lib/deviceSender.ts`) now validates pin/range/I2C/SPI/UART/WS2812 arguments synchronously before round-tripping to firmware. Bad calls (`setGpioState(999, "high")`, `setPwmState(0, 0, 5.0)`, malformed I2C addresses, `pioWs2812Update([[256, 0, 0]])`, etc.) now throw a typed error with `code: "invalid_argument"` and a `docs` URL instead of silently returning a `command_error` event. - - **Behaviour change to note**: scripts that previously relied on `setGpioState(badPin, …)` round-tripping and surfacing as a `command_error` event in `onMessage` will now throw synchronously from the `await` site. Catch the error or fix the argument - the `docs` field on the thrown Error points at the right reference page. - - **`@devicesdk/website`** - content + agent affordances: - - `/llms.txt` (curated index) and `/llms-full.txt` (full doc concat) now generated by Hugo. Per-page Markdown mirrors land at `/index.md` so agents can fetch raw docs without parsing HTML. - - New cookbook at `/docs/recipes/` with 10 task-shaped, single-page recipes (BME280, button→LED, KV counter, daily cron summary, WS2812 rainbow, OLED display, Discord webhook, HA entity, two-device RPC, watch device logs). - - New CLI doc pages: `/docs/cli/dev/`, `/docs/cli/build/`, `/docs/cli/login/`. New guide `/docs/guides/using-i2c/`. New single-page reference `/docs/concepts/device-api/`. New error reference under `/docs/errors/`. - - Changelog moved from `/docs/resources/changelog/` to `/docs/changelog/` (301 redirect added). - - Fixed `/docs/concepts/architecture/` page that documented a fictional `gpio_write` message protocol - now shows the real `set_gpio_state` shape and the typed helper `setGpioState`. - - Fixed `/docs/quickstart/` page that had two `## Step 4` headings. - -### Patch Changes - -- Updated dependencies [d03b5ae] - - @devicesdk/cli@0.5.0 diff --git a/packages/mcp/LICENSE b/packages/mcp/LICENSE deleted file mode 100644 index be3f7b28..00000000 --- a/packages/mcp/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/packages/mcp/README.md b/packages/mcp/README.md deleted file mode 100644 index 4cc9c0af..00000000 --- a/packages/mcp/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# @devicesdk/mcp - -Model Context Protocol server for DeviceSDK. Lets AI coding agents (Claude Desktop, Claude Code, Cursor, Continue.dev, etc.) drive your DeviceSDK projects: list devices, deploy scripts, flash firmware, tail logs, manage env vars. - -## Install - -```bash -npx -y @devicesdk/mcp --version -``` - -You don't usually run it directly - it's launched by your MCP-aware tool. Add this to `.mcp.json` in your project (or wherever your tool reads MCP config): - -```json -{ - "mcpServers": { - "devicesdk": { - "command": "npx", - "args": ["-y", "@devicesdk/mcp"] - } - } -} -``` - -`devicesdk init` writes this file for you when scaffolding a new project. - -## Authentication - -The server inherits the CLI's auth: it reads `~/.devicesdk/credentials.json` (created by `devicesdk login`). If you set `DEVICESDK_TOKEN` in the environment, that takes precedence - useful in CI or when you want a tighter-scoped token for your agent. - -## Tools exposed - -| Tool | What it does | -|------|--------------| -| `devicesdk_whoami` | Show the currently-authenticated user. | -| `devicesdk_status` | List devices in a project with their connection state. | -| `devicesdk_logs_tail` | Fetch the last N log entries for a device. | -| `devicesdk_env_list` | List env var keys for a project (values never returned). | -| `devicesdk_env_set` | Set one or more env vars on a project. | -| `devicesdk_deploy` | Build + deploy device scripts. | -| `devicesdk_docs_search` | Resolve a query to a docs URL on devicesdk.com. | - -Each tool wraps the equivalent `devicesdk --json` invocation, so the agent gets the same `{ success, result | error }` shape returned by the CLI. - -## Full docs - -The complete reference - install snippets per MCP host (Claude Desktop, Claude Code, Cursor, Continue.dev, Windsurf), auth model, troubleshooting - lives at ****. - -## See also - -- [`devicesdk init`](https://devicesdk.com/docs/cli/init/) - scaffolds a project with `.mcp.json` preconfigured. -- [Cookbook](https://devicesdk.com/docs/recipes/) - task-shaped recipes. -- [`@devicesdk/cli`](https://www.npmjs.com/package/@devicesdk/cli) - the CLI this server wraps. diff --git a/packages/mcp/package.json b/packages/mcp/package.json deleted file mode 100644 index 6f5757e8..00000000 --- a/packages/mcp/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@devicesdk/mcp", - "version": "0.2.6", - "description": "Model Context Protocol server for DeviceSDK - gives AI agents tools to deploy, flash, inspect, and query DeviceSDK projects.", - "author": "DeviceSDK ", - "license": "AGPL-3.0-only", - "homepage": "https://devicesdk.com/docs/", - "bugs": { - "url": "https://github.com/device-sdk/devicesdk/issues" - }, - "keywords": [ - "mcp", - "model-context-protocol", - "devicesdk", - "iot", - "ai-agent", - "claude", - "cursor", - "copilot" - ], - "main": "dist/index.js", - "bin": { - "devicesdk-mcp": "dist/index.js" - }, - "files": [ - "dist", - "LICENSE", - "README.md" - ], - "private": false, - "repository": { - "type": "git", - "url": "git+https://github.com/device-sdk/devicesdk.git", - "directory": "packages/mcp" - }, - "type": "module", - "scripts": { - "build": "tsc", - "start": "tsx src/index.ts", - "dev": "tsx src/index.ts", - "check-types": "tsc --noEmit", - "clean": "rm -rf dist" - }, - "dependencies": { - "@devicesdk/cli": "workspace:*", - "@modelcontextprotocol/sdk": "^1.0.4", - "execa": "^8.0.1" - }, - "devDependencies": { - "@repo/typescript-config": "workspace:*", - "@types/node": "catalog:", - "tsx": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts deleted file mode 100644 index 56bb3272..00000000 --- a/packages/mcp/src/index.ts +++ /dev/null @@ -1,301 +0,0 @@ -#!/usr/bin/env node - -/** - * @devicesdk/mcp - Model Context Protocol stdio server. - * - * Wraps the `devicesdk` CLI's --json modes as MCP tools so AI coding agents - * (Claude Code, Cursor, Continue.dev, …) can interact with DeviceSDK projects - * without learning the shell. Inherits auth from `~/.devicesdk/auth.json`. - */ - -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, - type Tool, -} from "@modelcontextprotocol/sdk/types.js"; -import { execa } from "execa"; - -interface CliResult { - success: boolean; - result?: unknown; - error?: string; - code?: string; - docs?: string; -} - -async function runCli(args: string[]): Promise { - try { - // We pass DEVICESDK_OUTPUT=json instead of appending --json so the call - // works for every subcommand uniformly - including the ones (`env set`, - // `env unset`, `deploy`) that don't declare a `--json` flag and would - // otherwise be rejected by Commander as an unknown option. - const { stdout } = await execa("devicesdk", args, { - env: { ...process.env, DEVICESDK_OUTPUT: "json" }, - reject: false, - }); - const trimmed = stdout.trim(); - if (!trimmed) { - return { success: false, error: "CLI returned no output" }; - } - // In NDJSON streaming modes (logs --tail --json) only the first frame - // matters for one-shot tool calls; we strip to the last newline-bounded - // JSON record to keep the tool deterministic. - const lastLine = trimmed.split("\n").filter(Boolean).at(-1) ?? trimmed; - try { - return JSON.parse(lastLine) as CliResult; - } catch { - return { - success: false, - error: `Invalid JSON from CLI: ${lastLine.slice(0, 200)}`, - }; - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: `Failed to spawn devicesdk: ${message}` }; - } -} - -function asToolResponse(result: CliResult) { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(result, null, 2), - }, - ], - isError: !result.success, - }; -} - -const TOOLS: Tool[] = [ - { - name: "devicesdk_whoami", - description: - "Show the currently-authenticated DeviceSDK user. Returns { id, email }.", - inputSchema: { - type: "object", - properties: {}, - additionalProperties: false, - }, - }, - { - name: "devicesdk_status", - description: - "List devices in a DeviceSDK project with their connection status. " + - "Project ID defaults to the one in `devicesdk.ts` in the current working directory.", - inputSchema: { - type: "object", - properties: { - project: { - type: "string", - description: "Project ID (overrides devicesdk.ts).", - }, - device: { - type: "string", - description: "Restrict to a single device by ID.", - }, - }, - additionalProperties: false, - }, - }, - { - name: "devicesdk_logs_tail", - description: - "Fetch the most recent log entries for a device. Returns { projectId, deviceId, entries[] }.", - inputSchema: { - type: "object", - properties: { - project: { type: "string" }, - device: { type: "string" }, - lines: { - type: "number", - description: "Number of entries to return (max 100, default 50).", - }, - level: { - type: "string", - enum: ["log", "info", "warn", "error", "debug"], - }, - }, - additionalProperties: false, - }, - }, - { - name: "devicesdk_env_list", - description: - "List env var keys for a project. Values are never returned by the API for security reasons.", - inputSchema: { - type: "object", - properties: { - project: { type: "string" }, - }, - additionalProperties: false, - }, - }, - { - name: "devicesdk_env_set", - description: - "Set one or more env vars on a project. The values are visible only to the device script via `this.env.VARS`.", - inputSchema: { - type: "object", - properties: { - project: { type: "string" }, - pairs: { - type: "array", - items: { type: "string" }, - description: - 'KEY=VALUE strings, e.g. ["DISCORD_WEBHOOK=https://..."].', - }, - }, - required: ["pairs"], - additionalProperties: false, - }, - }, - { - name: "devicesdk_deploy", - description: - "Build and deploy device scripts. Run from a directory containing `devicesdk.ts`.", - inputSchema: { - type: "object", - properties: { - device: { - type: "string", - description: "Deploy only one device (defaults to all).", - }, - message: { - type: "string", - description: "Deployment message / version note.", - }, - dryRun: { - type: "boolean", - description: "Validate without uploading.", - }, - }, - additionalProperties: false, - }, - }, - { - name: "devicesdk_docs_search", - description: - "Resolve a free-text query to the most relevant docs URL on devicesdk.com. Use to point an agent at canonical references for further reading.", - inputSchema: { - type: "object", - properties: { - query: { type: "string" }, - }, - required: ["query"], - additionalProperties: false, - }, - }, -]; - -const server = new Server( - { name: "devicesdk-mcp", version: "0.1.0" }, - { capabilities: { tools: {} } }, -); - -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: TOOLS, -})); - -server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - const a = (args ?? {}) as Record; - - switch (name) { - case "devicesdk_whoami": - return asToolResponse(await runCli(["whoami"])); - - case "devicesdk_status": { - const argv = ["status"]; - if (typeof a.project === "string") argv.push("--project", a.project); - if (typeof a.device === "string") argv.push("--device", a.device); - return asToolResponse(await runCli(argv)); - } - - case "devicesdk_logs_tail": { - const argv = ["logs"]; - if (typeof a.project === "string") argv.push(a.project); - if (typeof a.device === "string") argv.push(a.device); - if (typeof a.lines === "number") argv.push("--lines", String(a.lines)); - if (typeof a.level === "string") argv.push("--level", a.level); - return asToolResponse(await runCli(argv)); - } - - case "devicesdk_env_list": { - const argv = ["env", "list"]; - if (typeof a.project === "string") argv.push("--project", a.project); - return asToolResponse(await runCli(argv)); - } - - case "devicesdk_env_set": { - const pairs = Array.isArray(a.pairs) ? (a.pairs as string[]) : []; - if (pairs.length === 0) { - return asToolResponse({ - success: false, - error: "Pass at least one KEY=VALUE pair.", - code: "missing_pairs", - }); - } - const argv = ["env", "set", ...pairs]; - if (typeof a.project === "string") argv.push("--project", a.project); - return asToolResponse(await runCli(argv)); - } - - case "devicesdk_deploy": { - const argv = ["deploy"]; - if (typeof a.device === "string") argv.push("--device", a.device); - if (typeof a.message === "string") argv.push("-m", a.message); - if (a.dryRun === true) argv.push("--dry-run"); - return asToolResponse(await runCli(argv)); - } - - case "devicesdk_docs_search": { - // Lightweight search: fetch /llms.txt and grep for the query. The - // llms.txt is a curated index, so the first hit is usually the right - // page. Avoids depending on a separate search index. - const query = typeof a.query === "string" ? a.query.toLowerCase() : ""; - if (!query) { - return asToolResponse({ - success: false, - error: "query is required", - code: "missing_query", - }); - } - try { - const res = await fetch("https://devicesdk.com/llms.txt"); - if (!res.ok) { - return asToolResponse({ - success: false, - error: `llms.txt fetch failed: ${res.status}`, - }); - } - const text = await res.text(); - const matches = text - .split("\n") - .filter((line) => line.toLowerCase().includes(query)) - .slice(0, 10); - return asToolResponse({ - success: true, - result: { query, matches }, - }); - } catch (err) { - return asToolResponse({ - success: false, - error: `docs search failed: ${err instanceof Error ? err.message : String(err)}`, - }); - } - } - - default: - return asToolResponse({ - success: false, - error: `Unknown tool: ${name}`, - code: "unknown_tool", - }); - } -}); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json deleted file mode 100644 index 2ff0ca06..00000000 --- a/packages/mcp/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "@repo/typescript-config/base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "declaration": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} From e59bc60abaecaf3899ce03500f1151ffbf0510e4 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:09:57 +0000 Subject: [PATCH 08/16] docs: purge @devicesdk/mcp references now that the package is removed Follow-up to the previous commit (packages/mcp removal) - this is the actual doc/reference cleanup that commit's message described (a failed `git add` with a stale pathspec left it out of that commit; splitting into its own commit rather than amending). - AGENTS.md, README.md, CONTRIBUTING.md: drop the packages/mcp table rows and "CLI/MCP run on plain Node" phrasing (MCP is now part of the Bun server, not a separate Node package); add apps/server/src/mcp/ and apps/server/src/oauth/ to AGENTS.md's Source-of-Truth table plus a Server-architecture bullet describing the MCP/OAuth subsystems, matching how every other subsystem there is documented. - docs/public/cli/init.md: the two .mcp.json mentions now describe the bundled-server / HTTP form instead of the npx package. - pnpm-lock.yaml: regenerated by `pnpm install` after `git rm -r packages/mcp`. Historical CHANGELOG.md entries and docs/public/changelog.md's past-release notes are left untouched (they describe what shipped at the time). docs/public/mcp.md itself is rewritten in the next commit. Part of plan 003 (bundled MCP server + OAuth 2.1). --- AGENTS.md | 37 +++++++++++++++++++++++++++++-------- CONTRIBUTING.md | 3 +-- README.md | 5 ++--- docs/public/cli/init.md | 4 ++-- pnpm-lock.yaml | 25 ------------------------- 5 files changed, 34 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 33e6285d..cc959a45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,13 +54,14 @@ Docker): `devicesdk.sqlite` (WAL), ## Monorepo Architecture -pnpm + Turborepo. Bun is the **server runtime only** - the CLI/MCP run on plain +pnpm + Turborepo. Bun is the **server runtime only** - the CLI runs on plain Node for npm users. **`apps/server`** (`@devicesdk/server`) - THE backend. Bun + Hono + Chanfana (OpenAPI) + Zod + bun:sqlite. One process, one port (8080): REST API under `/v1/*`, device + watcher WebSockets, dashboard SPA static serving, OpenAPI docs -at `/api-docs`. +at `/api-docs`, and a bundled MCP server at `/mcp` (OAuth 2.1 or static API +tokens) for AI coding agents. **`apps/dashboard`** - Vue 3 + Quasar SPA. Local email/password auth (register/login). Built `dist/spa` is served same-origin by the server; @@ -76,8 +77,6 @@ in `~/.devicesdk/credentials.json` → mDNS auto-discovery (`devicesdk.local`). non-default setups or when mDNS is unavailable. `devicesdk dev` still uses the workerd simulator (convergence on the server runtime is a roadmap item). -**`packages/mcp`** - MCP server wrapping the CLI for AI agents. - **`apps/simulation`** - Vue UI for the CLI dev simulator (built dist embedded in CLI). @@ -139,8 +138,27 @@ image. - **Metrics**: `src/foundation/usageMetrics.ts` - 5-minute SQLite buckets in `device_usage`; windows 1h/12h/7d. No cost estimation (that was a cloud-billing concept). -- **Janitor**: `src/janitor.ts` hourly - expired sessions/CLI codes, old - logs/usage. +- **MCP**: `src/mcp/` - stateless Streamable HTTP MCP server at `POST /mcp` + (`@modelcontextprotocol/sdk` + `@hono/mcp`, `sessionIdGenerator: undefined`, + `enableJsonResponse: true` - a fresh `McpServer` per request, no + `Mcp-Session-Id`, no server-to-client push). Every `devicesdk_*` tool + re-enters the REST API in-process (`app.request(path, init, c.env)`, + forwarding the caller's own Authorization/Cookie header) instead of + duplicating query logic - one source of truth for validation and response + shapes. `devicesdk_docs_search` queries a SQLite FTS5 index built at server + build time from `docs/public/**/*.md` (`scripts/build-docs-index.ts`), not + a network call. GET/DELETE /mcp are 405 (stateless - nothing to resume or + terminate). +- **OAuth**: `src/oauth/` - a minimal OAuth 2.1 authorization server + additive to static API tokens: PKCE-required authorization-code grant, open + (rate-limited) dynamic client registration (RFC 7591), no refresh tokens. + Access tokens are `tokens` rows with `managed=1` and a real `expires_at` + (30 days) - revocable from the dashboard's Tokens page like any other + token. `foundation/auth.ts`'s `mcpAuth` wrapper adds a `WWW-Authenticate` + header on 401s so MCP clients discover + `/.well-known/oauth-protected-resource` automatically. +- **Janitor**: `src/janitor.ts` hourly - expired sessions/CLI codes, OAuth + codes, expired API tokens, old logs/usage. - **mDNS**: `src/foundation/mdns/` - a zero-dependency multicast-DNS responder (`node:dgram`) advertising the server as `.local` (default `devicesdk`) so LAN devices resolve it without a static IP. `dnsPacket.ts` is @@ -177,6 +195,9 @@ on the user's own server - that's the trust model). | Usage metrics | `apps/server/src/foundation/usageMetrics.ts` | | Watch WebSocket routes | `apps/server/src/endpoints/devices/wsRoutes.ts` | | Dashboard watch composable | `apps/dashboard/src/composables/useDeviceStream.ts` | +| Bundled MCP server (`/mcp`) | `apps/server/src/mcp/` | +| OAuth 2.1 authorization server | `apps/server/src/oauth/` | +| Docs FTS5 index builder | `apps/server/scripts/build-docs-index.ts` | | HA entity types/persistence | `packages/core` + `apps/server/src/endpoints/devices/{get,upsert}DeviceEntities.ts` | | ESP32/Pico image checksum patching | `apps/server/src/foundation/{esp32ImageChecksum,picoUf2Checksum}.ts` | | Endpoint patterns | `apps/server/src/endpoints/` (Hono + Chanfana + Zod) | @@ -230,7 +251,7 @@ on the user's own server - that's the trust model). - **Before every commit**, run `pnpm lint`. Do not commit if linting fails. - **Every PR must include a changeset** referencing every workspace package - touched (npm-published: `@devicesdk/core`, `@devicesdk/cli`, `@devicesdk/mcp`; + touched (npm-published: `@devicesdk/core`, `@devicesdk/cli`; private-with-changelog: `@devicesdk/server`, `@devicesdk/dashboard`, `@devicesdk/simulation`, `@devicesdk/website`). Create it early in the branch with `pnpm changeset` so CI can validate it. @@ -253,7 +274,7 @@ on the user's own server - that's the trust model). The monorepo uses `@changesets/cli` for versioning and release management. - **Public packages** (`private: false`) are published to npm by `pnpm release`: - `@devicesdk/core`, `@devicesdk/cli`, `@devicesdk/mcp`. + `@devicesdk/core`, `@devicesdk/cli`. - **Private packages** (`private: true`) are version-bumped and get changelog entries, but are **not** published to npm. This includes the runtime apps: `@devicesdk/server`, `@devicesdk/dashboard`, `@devicesdk/simulation`, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23b447a7..50f8eb99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,13 +29,12 @@ This is a pnpm + Turborepo monorepo: | Path | Package | Purpose | |---|---|---| -| `apps/server` | `@devicesdk/server` | Bun backend: REST API, device WebSockets, dashboard hosting | +| `apps/server` | `@devicesdk/server` | Bun backend: REST API, device WebSockets, dashboard hosting, bundled MCP server (`/mcp`) | | `apps/dashboard` | `@devicesdk/dashboard` | Vue 3 + Quasar dashboard SPA | | `apps/website` | `@devicesdk/website` | Vue 3 + Vite SSG marketing and docs site | | `apps/simulation` | `@devicesdk/simulation` | CLI dev simulator UI | | `packages/core` | `@devicesdk/core` | Shared types and `DeviceEntrypoint` base class (published to npm) | | `packages/cli` | `@devicesdk/cli` | `devicesdk` CLI (published to npm) | -| `packages/mcp` | `@devicesdk/mcp` | Model Context Protocol server (published to npm) | | `packages/typescript-config` | `@repo/typescript-config` | Shared tsconfig base | | `firmware/esp32`, `firmware/pico` | - | ESP32 / Pico W firmware | | `examples/*` | - | Example device projects | diff --git a/README.md b/README.md index 6135736e..1e58ef93 100644 --- a/README.md +++ b/README.md @@ -89,17 +89,16 @@ docker build -t devicesdk . ## Project Structure -pnpm + Turborepo monorepo. **Bun is the server runtime only**: the CLI and MCP run on plain Node for npm users. +pnpm + Turborepo monorepo. **Bun is the server runtime only**: the CLI runs on plain Node for npm users. | Package | Name | Description | |---|---|---| -| `apps/server` | `@devicesdk/server` | The backend: Bun + Hono + Chanfana + Zod + `bun:sqlite`. One process, one port: REST API (`/v1/*`), device + watcher WebSockets, dashboard SPA, OpenAPI docs (`/api-docs`) | +| `apps/server` | `@devicesdk/server` | The backend: Bun + Hono + Chanfana + Zod + `bun:sqlite`. One process, one port: REST API (`/v1/*`), device + watcher WebSockets, dashboard SPA, OpenAPI docs (`/api-docs`), and a bundled MCP server (`/mcp`) for AI coding agents | | `apps/dashboard` | `@devicesdk/dashboard` | Vue 3 + Quasar SPA: local email/password auth, project/device/token management. Served same-origin by the server | | `apps/simulation` | `@devicesdk/simulation` | Vue 3 device-simulation UI (static export consumed by the CLI `dev` command) | | `apps/website` | `@devicesdk/website` | Vue 3 + Vite SSG marketing & docs site | | `packages/core` | `@devicesdk/core` | Shared TypeScript types and the `DeviceEntrypoint` base class (published to npm) | | `packages/cli` | `@devicesdk/cli` | CLI tool (`devicesdk`): login, init, build, dev, deploy, flash, logs, status, inspect | -| `packages/mcp` | `@devicesdk/mcp` | Model Context Protocol server wrapping the CLI for AI agents | | `packages/typescript-config` | `@repo/typescript-config` | Shared tsconfig base | | `firmware/esp32` | | ESP32 firmware (ESP-IDF, WebSocket client) | | `firmware/pico` | | Raspberry Pi Pico W firmware (C++, lwIP WebSocket client) | diff --git a/docs/public/cli/init.md b/docs/public/cli/init.md index 744a1a01..21a23d45 100644 --- a/docs/public/cli/init.md +++ b/docs/public/cli/init.md @@ -30,7 +30,7 @@ Creates a new project directory with: - `.gitignore` - `AGENTS.md` - version-matched guidance for AI coding agents working in the project - `.cursor/rules/devicesdk.mdc` - Cursor rules pointing at `AGENTS.md` -- `.mcp.json` - preconfigures the `@devicesdk/mcp` server for MCP-aware agents +- `.mcp.json` - points MCP-aware agents at this server's bundled `/mcp` endpoint - `README.md` - quick reference for humans ## Interactive Mode @@ -98,7 +98,7 @@ my-project/ ├── .cursor/ │ └── rules/ │ └── devicesdk.mdc # Cursor rules -├── .mcp.json # MCP config (preconfigures @devicesdk/mcp) +├── .mcp.json # MCP config (points at this server's /mcp endpoint) ├── README.md # Human-facing readme ├── .devicesdk/ # Build output (generated) ├── tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bc9efbb..17058697 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,31 +391,6 @@ importers: specifier: 'catalog:' version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.9)(@vitest/coverage-istanbul@4.1.5)(happy-dom@15.11.7)(jsdom@28.1.0)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) - packages/mcp: - dependencies: - '@devicesdk/cli': - specifier: workspace:* - version: link:../cli - '@modelcontextprotocol/sdk': - specifier: ^1.0.4 - version: 1.29.0(zod@4.3.6) - execa: - specifier: ^8.0.1 - version: 8.0.1 - devDependencies: - '@repo/typescript-config': - specifier: workspace:* - version: link:../typescript-config - '@types/node': - specifier: 'catalog:' - version: 22.19.9 - tsx: - specifier: 'catalog:' - version: 4.21.0 - typescript: - specifier: 'catalog:' - version: 5.9.3 - packages/typescript-config: {} packages: From c5b9c03ea12bcb95538ee177484c70519494e185 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:15:30 +0000 Subject: [PATCH 09/16] docs(mcp): rewrite for the bundled /mcp endpoint Full rewrite of docs/public/mcp.md around the server-bundled MCP endpoint instead of the removed @devicesdk/mcp npm package: HTTP .mcp.json quickstart, per-host config verified against each host's current docs (Claude Code's `claude mcp add --transport http`, Cursor's url-only mcpServers shape, VS Code's servers/type:http shape at .vscode/mcp.json, OpenCode's opencode.json mcp/type:remote shape), OAuth-recommended / API-token-alternative authentication section, the real 15-tool table (replacing the old CLI-wrapper 7-tool list), an honest note that devicesdk_device_logs currently always surfaces the REST endpoint's 410 deprecation, and an mDNS section. Frontmatter (title/description/weight/ social_image) keeps the same keys the site's build-content.ts expects. Verified: apps/website's own build-content.ts (gray-matter + markdown-it, not my server-side indexer) parses the new frontmatter and generates 68 pages including /docs/mcp/ with the expected title/description; apps/server's docs-index builder re-indexes it correctly at the same path; `pnpm lint --filter @devicesdk/website` and `pnpm check-types --filter @devicesdk/website` both exit 0. Part of plan 003 (bundled MCP server + OAuth 2.1). --- docs/public/mcp.md | 159 ++++++++++++++++++++------------------------- 1 file changed, 72 insertions(+), 87 deletions(-) diff --git a/docs/public/mcp.md b/docs/public/mcp.md index 26e5b534..f73933d7 100644 --- a/docs/public/mcp.md +++ b/docs/public/mcp.md @@ -1,172 +1,157 @@ --- -title: MCP server (@devicesdk/mcp) -description: Drive DeviceSDK from OpenCode, Claude, Cursor, Continue, Windsurf and other MCP-aware coding agents +title: MCP server (built into DeviceSDK) +description: Connect any MCP-aware AI agent to your DeviceSDK server over HTTP - no install, OAuth or API-token auth, offline docs search weight: 28 social_image: /og-images/docs/mcp.png --- -`@devicesdk/mcp` is a [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes DeviceSDK as a set of tools your AI agent can call directly - list devices, deploy scripts, tail logs, set env vars, search the docs. The agent never has to learn the shell. +The DeviceSDK server exposes [Model Context Protocol](https://modelcontextprotocol.io/) tools directly at `/mcp` - list projects and devices, check connection status, read metrics, manage env vars, upload and deploy scripts, send hardware commands, search the docs. There is nothing to install: any MCP-aware agent that speaks Streamable HTTP can point straight at your server. -Because it wraps the CLI, it talks to whatever DeviceSDK server the CLI is configured against - the self-hosted server you authenticated against with `devicesdk login` (the CLI auto-discovers it via mDNS, or you can pass `--host `). There is no managed cloud. - -It's a thin wrapper over the `devicesdk` CLI's `--json` mode, so you get the same auth, the same error messages, and the same `{ success, result | error, code, docs }` shape the API returns. +`/mcp` is stateless (no session to keep alive, no extra process to run) and version-matched to your server - the tools and the docs search index always reflect exactly what your server runs, not whatever the latest npm release happens to say. ## Quickstart -If you ran `devicesdk init` recently, you already have a `.mcp.json` in your project pointing at `@devicesdk/mcp` - open the project in any MCP-aware tool and it just works. - -For an existing project, drop this into a `.mcp.json` at the project root: +If you ran `devicesdk init` recently, you already have a `.mcp.json` in your project pointing at your server: ```json { "mcpServers": { "devicesdk": { - "command": "npx", - "args": ["-y", "@devicesdk/mcp"] + "type": "http", + "url": "http://devicesdk.local:8080/mcp" } } } ``` -Then reload your agent and run `devicesdk login` once so the MCP server can find your auth token and server URL. The CLI auto-discovers your server via mDNS; if that doesn't work on your network, pass `--host http://:8080`. - -## Install per host +For an existing project, drop that into `.mcp.json` at the project root - swap in your server's actual host if `devicesdk.local` doesn't resolve on your network (see [mDNS](#mdns) below). Claude Code and Cursor both read this same `mcpServers` block automatically. ### Claude Code -If you use Claude Code in a project that has `.mcp.json`, the server registers automatically on session start. To register globally instead: - ```bash -claude mcp add devicesdk -- npx -y @devicesdk/mcp +claude mcp add --transport http devicesdk http://devicesdk.local:8080/mcp ``` -### OpenCode +Or rely on the project's `.mcp.json` above - Claude Code reads it automatically on session start. + +### Cursor -OpenCode reads `.mcp.json` from the project root automatically. To register the server globally, add it to `~/.config/opencode/opencode.json`: +Cursor reads the same `.mcp.json` (project root) or a global `~/.cursor/mcp.json`. Cursor infers the HTTP transport from the presence of `url` - the `type` field above is harmless but not required: ```json { - "mcp": { + "mcpServers": { "devicesdk": { - "type": "local", - "command": ["npx", "-y", "@devicesdk/mcp"] + "url": "http://devicesdk.local:8080/mcp" } } } ``` -Restart OpenCode for the config change to take effect. - -### Claude Desktop +### VS Code -Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): +VS Code uses a different file and root key - `.vscode/mcp.json`, with `servers` instead of `mcpServers`: ```json { - "mcpServers": { + "servers": { "devicesdk": { - "command": "npx", - "args": ["-y", "@devicesdk/mcp"] + "type": "http", + "url": "http://devicesdk.local:8080/mcp" } } } ``` -Restart Claude Desktop. The DeviceSDK tools appear in the 🔨 picker. - -### Cursor - -Cursor reads `.mcp.json` from the project root (same shape as above). It also supports a global `~/.cursor/mcp.json`. Reload the workspace after adding. - -### Continue.dev +### OpenCode -Add to your `~/.continue/config.json`: +Add to `opencode.json` (project root) or `~/.config/opencode/opencode.json` (global): ```json { - "experimental": { - "modelContextProtocolServers": [ - { - "transport": { - "type": "stdio", - "command": "npx", - "args": ["-y", "@devicesdk/mcp"] - } - } - ] + "mcp": { + "devicesdk": { + "type": "remote", + "url": "http://devicesdk.local:8080/mcp", + "enabled": true + } } } ``` -### Windsurf, Zed, JetBrains Junie, others +### Other hosts -Any MCP-aware client that accepts a stdio server with a `command` + `args` will work. Use the same `npx -y @devicesdk/mcp` invocation. +Any MCP-aware client that supports the Streamable HTTP transport will work - point it at `http:///mcp` and check your host's docs for its remote-server config syntax. ## Authentication -The MCP server inherits the CLI's authentication. In order of precedence: +### OAuth (recommended) + +The first connection triggers OAuth automatically: your MCP host opens a browser, you log into the DeviceSDK dashboard if you aren't already, and a consent screen asks you to approve access for that client. Approving mints an API token - visible and revocable anytime in the dashboard's **Tokens** page - that expires automatically after 30 days. When it expires, the host simply re-prompts for consent; there's no refresh token to manage or lose track of. -1. **`DEVICESDK_TOKEN`** environment variable - a `dsdk_…` token, best for CI or when you want a tighter-scoped token for the agent than for your CLI. Pair it with `DEVICESDK_API_URL` to point at your server. -2. **`~/.devicesdk/credentials.json`** - written by `devicesdk login` (with or without `--host`); stores both the tokens and the server host. Refresh tokens rotate automatically. +Hosts with native MCP OAuth support (Claude Code, Cursor, VS Code, OpenCode, and others) handle the whole flow without any extra configuration - the plain `.mcp.json` blocks above are enough. -If neither is present, every tool returns `{ success: false, code: "missing_credentials", docs: "..." }`. +### API token (alternative - e.g. headless setups) -To scope an agent more tightly, generate a token in the dashboard's *Tokens* page and pass it through your MCP host's environment. For Claude Desktop, that means adding an `env` block in the config: +If your host doesn't support OAuth, or you want a static credential (CI, headless agents, hosts without a browser), create an API token in the dashboard's Tokens page - unlike OAuth-minted tokens, these never expire - and pass it as a Bearer header: ```json { "mcpServers": { "devicesdk": { - "command": "npx", - "args": ["-y", "@devicesdk/mcp"], - "env": { "DEVICESDK_TOKEN": "dsdk_…" } + "type": "http", + "url": "http://devicesdk.local:8080/mcp", + "headers": { "Authorization": "Bearer " } } } } ``` +**Troubleshooting**: some MCP hosts refuse to run OAuth discovery against a plain-`http://` (non-TLS) authorization server unless the host is `localhost`. If the OAuth prompt never appears against a LAN server, use the Bearer-header method above, or put the server behind TLS. + ## Tools | Tool | What it does | |------|---| -| `devicesdk_whoami` | Show the currently-authenticated user. | -| `devicesdk_status` | List devices in a project with their connection state, current version, and last-seen time. Defaults to the project in `devicesdk.ts` in the cwd. | -| `devicesdk_logs_tail` | Fetch the most recent N log entries for a device (max 100). Filters by level. Returns the same shape as `devicesdk logs --json`. | -| `devicesdk_env_list` | List env var keys for a project. The API never returns values, so neither does this tool - read them inside your script with `this.env.VARS.get`. | -| `devicesdk_env_set` | Set one or more `KEY=VALUE` pairs on a project. | -| `devicesdk_deploy` | Build and deploy device scripts. Supports `--dry-run` and per-device targeting. | -| `devicesdk_docs_search` | Resolve a free-text query to relevant URLs from `/llms.txt`. Use to point yourself at canonical references for further reading. | - -Every tool returns `{ success, result | error, code?, docs? }`. On `success: false` the `docs` field points at the page on this site explaining the error. - -## Example session - -``` -You: list my devices in the orchard project, then tail the last 20 errors from the broken one - -Agent: [calls devicesdk_status → orchard has 4 devices: sensor-east is offline] - [calls devicesdk_logs_tail with level=error, lines=20, project=orchard, device=sensor-east] - sensor-east lost connectivity 14 minutes ago. The last 20 error logs show - "BME280 not detected at 0x76" repeating every minute since 09:14 UTC. That - error pattern matches the I2C-wiring case in /docs/recipes/read-bme280/ - - check the SDA/SCL wires haven't come loose. -``` +| `devicesdk_whoami` | Show the currently-authenticated user (id, email, name, limits, usage). | +| `devicesdk_list_projects` | List every project owned by the caller. | +| `devicesdk_list_devices` | List devices in a project. | +| `devicesdk_device_status` | Live connection status for a device (connected, connected_since, current script version). | +| `devicesdk_device_metrics` | Time-bucketed usage metrics (messages, bytes, cron fires) for a device. | +| `devicesdk_project_metrics` | Aggregated usage metrics across every device in a project. | +| `devicesdk_env_list` | List env var keys for a project. The API never returns values, so neither does this tool. | +| `devicesdk_env_set` | Set one or more env vars on a project. | +| `devicesdk_env_delete` | Delete a single env var. | +| `devicesdk_list_script_versions` | List uploaded script versions for a device, flagging the current one. | +| `devicesdk_upload_script` | Upload a new script version and deploy it immediately. | +| `devicesdk_deploy_version` | Activate a previously-uploaded version (rollback or promote). | +| `devicesdk_send_command` | Send a hardware command (GPIO, I2C, SPI, UART, display, reboot, ...) to a connected device and wait for its response. | +| `devicesdk_device_logs` | Point-in-time device logs. Currently always returns a deprecation notice pointing at the live watcher WebSocket - use the dashboard's live log view instead until a snapshot API exists. | +| `devicesdk_docs_search` | Full-text search over an **offline** copy of these docs matching your server's version - no internet call, so results can lag the live site until your server image is updated. | + +Every tool returns the same `{ success, result | error, code? }` shape the REST API does; the MCP result's `isError` mirrors `success`. + +**Building and deploying TypeScript stays in the CLI.** The server cannot bundle a local TypeScript project - `devicesdk_upload_script` takes already-bundled JavaScript. Run `devicesdk build` / `devicesdk deploy` (or have the agent run them) for anything that starts from `devicesdk.ts` and `src/devices/*.ts`. + +## mDNS + +`devicesdk.local` resolves automatically when your agent host and DeviceSDK server share a LAN and the client OS supports mDNS/Bonjour (macOS and most Linux desktops out of the box; Windows usually needs Bonjour installed). If it doesn't resolve, use the server's IP address or a real hostname instead. ## Troubleshooting -- **"command not found: devicesdk"** during a tool call. The server shells out to the CLI, so the CLI must be on `PATH` (or installed via the `@devicesdk/mcp` package's bundled dependency). Run `npm install -g @devicesdk/cli` if you don't have it project-locally, or run the agent from inside a project where `devicesdk` is available via `npx`. -- **Every tool returns `missing_credentials`.** Run `devicesdk login` once, or set `DEVICESDK_TOKEN` in the MCP host's env block. -- **Tools list shows up empty.** Some hosts cache the tool catalog. Restart the agent / reload the workspace after editing `.mcp.json`. -- **Tools work but the agent doesn't pick the right one.** Drop a hint in your prompt: "use the `devicesdk_status` tool to check connectivity, then `devicesdk_logs_tail` to see why." +- **Every tool call returns 401 / `missing_credentials`.** The OAuth flow hasn't completed, or the token was revoked. Re-trigger your host's OAuth flow, or switch to a dashboard-created API token (see above). +- **The OAuth consent screen never appears.** See the plain-HTTP note under Authentication above. +- **Tools list shows up empty, or shows stale tools after a server upgrade.** Some hosts cache the tool catalog. Restart the agent / reload the workspace. +- **`devicesdk_upload_script` fails with a validation error.** It expects already-bundled JavaScript, not raw TypeScript - build first (`devicesdk build`) and pass the bundle content. ## What MCP is, in two sentences -[Model Context Protocol](https://modelcontextprotocol.io/) is a standard for exposing typed tools and resources to LLMs. The Anthropic-stewarded MCP registry has thousands of servers; everything from Stripe to Linear to Supabase ships one, and most modern coding agents speak it natively. +[Model Context Protocol](https://modelcontextprotocol.io/) is a standard for exposing typed tools and resources to LLMs. Most modern coding agents speak it natively; pointing one at `/mcp` is the same shape of integration as connecting it to Stripe, Linear, or any other MCP server - just self-hosted, alongside your devices. ## See also - [`devicesdk init`](/docs/cli/init/) - scaffolds `.mcp.json` for new projects. -- [Cookbook](/docs/recipes/) - task-shaped recipes the agent can crib from. -- [Error reference](/docs/errors/) - the codes the MCP tools surface. +- [Cookbook](/docs/recipes/) - task-shaped recipes an agent can crib from. +- [Error reference](/docs/errors/) - the codes REST responses (and therefore MCP tool results) surface. +- [Self-hosting guide](/docs/guides/self-hosting/) - TLS, `MDNS_HOSTNAME`, and other server config that affects how agents reach `/mcp`. - [Agent skills manifest](/.well-known/agent-skills/index.json) - for hosts that consume the [agentskills.io](https://schemas.agentskills.io/) discovery schema. -- npm: [`@devicesdk/mcp`](https://www.npmjs.com/package/@devicesdk/mcp), [`@devicesdk/cli`](https://www.npmjs.com/package/@devicesdk/cli) From 16f7a8ff2bdd5d8d70acd4e90cfa0ecf0a3a140d Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:31:29 +0000 Subject: [PATCH 10/16] fix(docker): build the docs FTS5 index and copy it into the runtime image Adds the docs-index build step (Step 3a) to the serverbuild stage and copies docs-index.sqlite + sets DOCS_INDEX_PATH in the runtime stage. .dockerignore excluded the whole docs/ directory from the build context, so stage 1's `COPY . .` never actually brought docs/public in - the RUN step failed with "Docs directory not found: /repo/docs/public". Removed the bare `docs` line; docs/public is 61 small markdown files (384K) with nothing sensitive, and now the only place it's used to build a Docker image is compiled into docs-index.sqlite - no raw markdown ships in the runtime image (verified: `find / -name '*.md'` in a running container only turns up the pre-existing migrations/README.md). Verified end-to-end: full `docker build` succeeds, and inside the running container /mcp lists all 15 tools and devicesdk_docs_search returns real BM25-ranked results against the built index. --- .dockerignore | 1 - Dockerfile | 10 +++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 5d90e452..f8e14db0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,5 @@ firmware/esp32/build firmware/esp32/managed_components firmware/pico/build apps/website -docs data *.log diff --git a/Dockerfile b/Dockerfile index 11f917d0..2ec0a1dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,12 @@ WORKDIR /repo COPY --from=build /repo /repo RUN cd apps/server \ && bun build src/server.ts --target=bun --outfile /out/server.js +# Build-time SQLite FTS5 index of docs/public/**/*.md for devicesdk_docs_search +# (offline, version-pinned to this image - see apps/server/scripts/build-docs-index.ts). +# Docs reach the build context via stage 1's `COPY . .` (.dockerignore does not +# exclude docs/, since apps/server's docs-index build needs docs/public). +RUN cd apps/server \ + && bun run scripts/build-docs-index.ts ../../docs/public /out/docs-index.sqlite # ---- stage 2.5: fetch prebuilt firmware binaries (best-effort) ---- # Firmware workflows publish versioned releases (tags firmware-esp32@vX.Y.Z / @@ -65,6 +71,7 @@ RUN mkdir -p /firmwares && cd /firmwares \ FROM oven/bun:1.3.14-slim WORKDIR /app COPY --from=serverbuild /out/server.js /app/server.js +COPY --from=serverbuild /out/docs-index.sqlite /app/docs-index.sqlite COPY --from=build /repo/apps/server/migrations /app/migrations COPY --from=build /repo/apps/dashboard/dist/spa /app/public COPY --from=firmware /firmwares /app/firmwares-dist @@ -73,7 +80,8 @@ ENV PORT=8080 \ DATA_DIR=/data \ PUBLIC_DIR=/app/public \ MIGRATIONS_DIR=/app/migrations \ - FIRMWARES_DIST_DIR=/app/firmwares-dist + FIRMWARES_DIST_DIR=/app/firmwares-dist \ + DOCS_INDEX_PATH=/app/docs-index.sqlite # Run as an unprivileged user. The bun image ships a `bun` group/user (uid 1000), # so reuse it rather than creating a new account that may collide with the host. From 6df3693bd1a04ec89ed226a8f027e24866f70e5f Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:31:37 +0000 Subject: [PATCH 11/16] chore: add changeset for the bundled MCP server Minor bump for @devicesdk/server (new /mcp + OAuth subsystem) and @devicesdk/cli (init scaffolds .mcp.json for the bundled endpoint instead of @devicesdk/mcp), patch for @devicesdk/website (MCP docs page rewrite). No entry for @devicesdk/mcp - the package is deleted, so changesets cannot reference it. --- .changeset/bundled-mcp-server.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/bundled-mcp-server.md diff --git a/.changeset/bundled-mcp-server.md b/.changeset/bundled-mcp-server.md new file mode 100644 index 00000000..46931d13 --- /dev/null +++ b/.changeset/bundled-mcp-server.md @@ -0,0 +1,9 @@ +--- +'@devicesdk/server': minor +'@devicesdk/cli': minor +'@devicesdk/website': patch +--- + +Add a stateless Streamable-HTTP MCP server bundled into `@devicesdk/server` at `/mcp` - 15 tools covering projects, devices, env vars, script versions, commands, and offline docs search, authenticated via OAuth 2.1 (PKCE + dynamic client registration) or existing API tokens. Every tool re-enters the REST API in-process so behavior always matches the server's own version. + +This replaces the standalone `@devicesdk/mcp` npm package, which is removed - `devicesdk init` now scaffolds `.mcp.json` pointing at the server's own `/mcp` endpoint instead of `npx @devicesdk/mcp`. The docs site's MCP page is rewritten to match. From 6dbb381ccd970fd8c153ee4bd638d32de164bcae Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:34:54 +0000 Subject: [PATCH 12/16] docs(troubleshoot): record the .dockerignore docs/ exclusion and bun test src/tests gotchas Two things this task hit that cost real debugging time and will bite again: (1) .dockerignore's blanket `docs` exclusion silently breaks any future Docker build step that reads docs/public, no matter how many COPY --from stages sit between it and the RUN that needs it; (2) apps/server's plain `test` script (`bun test src`) never runs tests/e2e or tests/unit - only `test:e2e`/`test:coverage` do - so a green `pnpm test --filter @devicesdk/server` is not proof those suites passed. --- TROUBLESHOOT.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/TROUBLESHOOT.md b/TROUBLESHOOT.md index 51c41ba6..09058a91 100644 --- a/TROUBLESHOOT.md +++ b/TROUBLESHOOT.md @@ -1,5 +1,19 @@ # Troubleshooting Log +### `docker build` fails on a docs-consuming build step with "directory not found", even though the Dockerfile does `COPY . .` +**Date**: 2026-07-08 +**Question/Problem**: Adding a `RUN` step to the `serverbuild` stage that reads `docs/public/**/*.md` (`apps/server/scripts/build-docs-index.ts`, for the `/mcp` docs-search FTS5 index) failed with `Docs directory not found: /repo/docs/public`, even though stage 1 does `COPY . .` into `/repo` and stage 2 does `COPY --from=build /repo /repo`. +**Root Cause**: `.dockerignore` had a bare `docs` line. `.dockerignore` exclusions apply to the **build context** sent to the daemon before any stage runs - they are not a per-`COPY` filter, so no later stage can retrieve a path `.dockerignore` excluded, no matter how many `COPY --from=` hops happen afterward. The line was originally added because only `apps/website` consumed `docs/public` (and `apps/website` itself is `.dockerignore`d, since the Docker image doesn't build the website); once `apps/server`'s build gained its own reason to read `docs/public`, the blanket exclusion silently broke it. +**Solution**: Removed the bare `docs` line from `.dockerignore`. `docs/public` is ~60 small Markdown files (under 400K) with no secrets, so including it in the build context is cheap and safe - the runtime image still only ships the *compiled* `docs-index.sqlite` (verified with `find / -xdev -name '*.md'` inside a running container: nothing from `docs/public` leaks in, only the pre-existing unrelated `apps/server/migrations/README.md`). +**Rule**: When a Dockerfile step reads a path under `COPY . .`, check `.dockerignore` for that path *first* if the read fails - grep it before adding debug `RUN ls` steps. A blanket top-level directory exclusion (`docs`, not `apps/website/docs` or similar) silently forecloses every future consumer of that directory, not just the one it was written for. + +### `pnpm test --filter @devicesdk/server` (`bun test src`) silently skips `tests/e2e/` and `tests/unit/` - a green run does not mean those suites passed +**Date**: 2026-07-08 +**Question/Problem**: After adding `apps/server/tests/e2e/mcp.test.ts`, `apps/server/tests/e2e/oauth.test.ts`, and `apps/server/tests/unit/docs-index.test.ts`, running the command CLAUDE.md lists as "server unit tests" (`pnpm test --filter @devicesdk/server`) reported a normal-looking pass with a *lower* test count than expected - it never ran the new files at all, and gave no error or warning that it hadn't. +**Root Cause**: `apps/server/package.json` has three different test scripts with different scopes: `"test": "bun test src"`, `"test:e2e": "bun test src tests"`, `"test:coverage": "bun run scripts/coverage-gate.ts"` (which internally also runs `bun test src tests --coverage`). `bun test ` filters test files by a **substring match on file path** - `bun test src` only ever discovers files whose path contains `src`, so anything under `apps/server/tests/` (e2e and unit alike) is never loaded, and bun does not warn that a pattern matched fewer files than exist. The root `CLAUDE.md`'s command table lists only `pnpm test --filter @devicesdk/server` as "server unit tests", which reads as the full gate but isn't. +**Solution**: For a true full-suite run (e.g. before reporting a task complete, or in CI-equivalent verification), run `bun test src tests` (or `pnpm test:e2e --filter @devicesdk/server` / `pnpm test:coverage --filter @devicesdk/server`) directly from `apps/server`, not the bare `test` script. +**Rule**: In this repo, `apps/server`'s plain `test` script is a subset (`src/` colocated tests only). Anything under `apps/server/tests/` (e2e, unit) needs `test:e2e` or `test:coverage` to run at all - never trust a green `pnpm test --filter @devicesdk/server` alone as evidence that e2e/unit tests pass; confirm the file count in the summary line matches what you expect, or run the fuller command explicitly. + ### Dashboard won't load: assets served with empty MIME type (cors drops `Bun.file` Content-Type) **Date**: 2026-06-29 **Question/Problem**: Opening the self-hosted dashboard (`docker compose up`, `http://localhost:8080`) shows a blank page. Console: `Refused to apply style from '.../assets/index-*.css' because its MIME type ('') is not a supported stylesheet MIME type` and `Failed to load module script: ... responded with a MIME type of ""`. The `/assets/*.css` and `*.js` responses have an **empty `Content-Type`**. From ca29ada6d7d1051eb41f3226030a07cb0fb6e553 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:39:51 +0000 Subject: [PATCH 13/16] docs: fix two @devicesdk/mcp references the Step 12 verify grep's file filter missed The plan's own verify command (grep --include='*.ts' --include='*.json' --include='*.md') is blind to plain-text and .txt files. Broadening the grep past that filter turned up two live, stale references: - NOTICE: the npm-published-packages list still named @devicesdk/mcp (removed, so no longer accurate). - apps/website/src/llms.txt: the hand-authored LLM-crawler manifest (copied verbatim into static/llms.txt by build-content.ts) still pointed agents at `npx @devicesdk/mcp` instead of the bundled /mcp endpoint - the same fix already applied to docs/public/mcp.md. Left untouched (correctly, per the plan's own grep exclusions and by the same logic applied to CHANGELOG.md files): docs/public/changelog.md (historical release-announcement record), init.test.ts's negative assertion, and this branch's own new prose in tools.ts's comment and the changeset. --- NOTICE | 2 +- apps/website/src/llms.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NOTICE b/NOTICE index 4f3f2499..da76652f 100644 --- a/NOTICE +++ b/NOTICE @@ -87,6 +87,6 @@ categories of licenses in the dependency tree are: - ISC No GPL or AGPL dependencies are used in the published npm packages -(@devicesdk/core, @devicesdk/cli, @devicesdk/mcp). The server application +(@devicesdk/core, @devicesdk/cli). The server application (apps/server) is licensed under AGPL-3.0 and may include AGPL-compatible dependencies. diff --git a/apps/website/src/llms.txt b/apps/website/src/llms.txt index 411ed75e..f370427e 100644 --- a/apps/website/src/llms.txt +++ b/apps/website/src/llms.txt @@ -87,7 +87,7 @@ ## Agent integrations -- [MCP server (@devicesdk/mcp)](https://devicesdk.com/docs/mcp/index.md): drive DeviceSDK from Claude / Cursor / Continue / Windsurf via Model Context Protocol - install snippets per host, tool reference, auth model +- [MCP server (built into DeviceSDK)](https://devicesdk.com/docs/mcp/index.md): connect any MCP-aware agent to your server's own `/mcp` endpoint over Streamable HTTP - no install, OAuth 2.1 or API-token auth, offline docs search ## Optional From 3b60cd6950bf45c4316ad63420340d2fd6aa05c9 Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:47:48 +0000 Subject: [PATCH 14/16] docs(plans): mark plan 003 (bundled MCP server) DONE Reviewed and approved: OAuth 2.1 flow, MCP tool loopback, and the auth.ts token-expiry change all verified independently (re-ran the full test suites, lint, typecheck, and a scoped build rather than trusting the executor's report). Co-Authored-By: Claude Sonnet 5 --- plans/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/README.md b/plans/README.md index 8cb64b01..2002db0c 100644 --- a/plans/README.md +++ b/plans/README.md @@ -21,7 +21,7 @@ done. | Plan | Title | Priority | Effort | Depends on | Status | |------|-------|----------|--------|------------|--------| -| 003 | Bundled stateless MCP server at /mcp (Streamable HTTP, OAuth 2.1, removes @devicesdk/mcp) | P1 | L | — | TODO | +| 003 | Bundled stateless MCP server at /mcp (Streamable HTTP, OAuth 2.1, removes @devicesdk/mcp) | P1 | L | — | DONE | | 002 | Agent discoverability: Claude Code plugin, README/agent metadata (4 phases; Phase 1 MCP-registry publish REJECTED) | P1 | M | 003 | TODO | | 001 | Home Assistant HACS custom integration (full v1, phased) | P1 | L | — | TODO | | 004 | I2C sensor driver library in @devicesdk/core (BME280, SHT3x, BH1750, ADS1115, INA219) | P1 | M | — | TODO | From 310d3da77549c266da40fb3a9715ac29463b072b Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:44:31 +0000 Subject: [PATCH 15/16] fix(server): un-deprecate GET .../logs, self-hosted SQLite has no rows-read quota to protect The 410 dated from the Cloudflare Durable Object era, where polling this endpoint burned a per-device daily rows-read quota. The self-host refactor replaced that with local SQLite, so there is no quota left to protect; the endpoint now returns a real cursor-paginated page of persisted logs (newest first, optional level filter), scoped to the caller's project. This resolves the one open item from the bundled-MCP-server review: devicesdk_device_logs was a permanently-410ing stub. The watcher WebSocket stays the dashboard's live-tailing path, unchanged. --- .changeset/bundled-mcp-server.md | 2 + TROUBLESHOOT.md | 7 +- apps/dashboard/src/components/DeviceLogs.vue | 11 +- .../src/composables/useDeviceStream.ts | 5 +- apps/dashboard/src/services/api.service.ts | 3 +- apps/server/src/endpoints/logs/listLogs.ts | 136 +++++++++---- apps/server/src/mcp/tools.ts | 9 +- apps/server/tests/e2e/logs.test.ts | 179 +++++++++++++----- docs/public/mcp.md | 2 +- plans/014-entity-state-history.md | 5 +- 10 files changed, 264 insertions(+), 95 deletions(-) diff --git a/.changeset/bundled-mcp-server.md b/.changeset/bundled-mcp-server.md index 46931d13..91f7feab 100644 --- a/.changeset/bundled-mcp-server.md +++ b/.changeset/bundled-mcp-server.md @@ -7,3 +7,5 @@ Add a stateless Streamable-HTTP MCP server bundled into `@devicesdk/server` at `/mcp` - 15 tools covering projects, devices, env vars, script versions, commands, and offline docs search, authenticated via OAuth 2.1 (PKCE + dynamic client registration) or existing API tokens. Every tool re-enters the REST API in-process so behavior always matches the server's own version. This replaces the standalone `@devicesdk/mcp` npm package, which is removed - `devicesdk init` now scaffolds `.mcp.json` pointing at the server's own `/mcp` endpoint instead of `npx @devicesdk/mcp`. The docs site's MCP page is rewritten to match. + +`GET /v1/projects/:projectId/devices/:deviceId/logs` is un-deprecated: it was a permanent 410 in the Cloudflare era to protect a Durable Object rows-read quota that no longer exists on the self-hosted SQLite server, so it now returns a real cursor-paginated page of persisted logs. This is what makes `devicesdk_device_logs` a working MCP tool instead of a stub. The watcher WebSocket (`/watch?backfillLimit=N`) remains the dashboard's live-tailing path and is unchanged. diff --git a/TROUBLESHOOT.md b/TROUBLESHOOT.md index 09058a91..3af03c8d 100644 --- a/TROUBLESHOOT.md +++ b/TROUBLESHOOT.md @@ -366,10 +366,11 @@ uint8_t com_pins = (height == 32) ? 0x02 : 0x12; **Rule**: For any new SSD1306-family panel that isn't the canonical 128×32, expect alternating COM pins. The clue that you have the wrong config is diagnostic patterns that show up as every-other-row stripes, not as rectangles. ### `devicesdk logs --tail` exits / fails behind a corporate proxy -**Date**: 2026-05-01 +**Date**: 2026-05-01 (superseded 2026-07-08, see below) **Question/Problem**: Since May 2026 the CLI `logs` and `logs --tail` commands open a WebSocket to `/v1/projects/.../devices/.../watch` instead of polling the deprecated `/logs` HTTP endpoint. Some corporate proxies strip the `Upgrade: websocket` header or block 101 responses, leaving the CLI unable to connect. -**Root Cause**: The deprecation removed the HTTP polling fallback. The watcher endpoint is the only path that delivers logs. -**Solution**: Open your self-hosted dashboard at `http(s):///projects//devices/` - the logs panel uses the same WebSocket and works in any browser the user can already reach. If neither WS nor the dashboard is reachable, raise the issue with the network operator (the watcher socket is required for the runtime UI). Do **not** reintroduce a polling fallback - the burn pattern that triggered the migration would recur. +**Root Cause**: At the time, the `/logs` HTTP endpoint had just been made a permanent 410 - it was Cloudflare-era, where every `device_logs` read/write billed against a Durable Object's daily rows-read/rows-written quota, and naive polling clients had burned through it (see the row-budget entry above). The watcher WebSocket was the only path left that delivered logs. +**Solution (2026-05-01, now historical)**: Open your self-hosted dashboard, which uses the same WebSocket. Do not reintroduce a polling fallback. +**Update (2026-07-08)**: The self-host refactor replaced the Durable Object with the server's own local SQLite (`device_logs` table) - there is no external rows-read quota to protect anymore, so `GET /v1/projects/:projectId/devices/:deviceId/logs` (cursor/limit/level paged, newest first) is un-deprecated and works normally again; see `apps/server/src/endpoints/logs/listLogs.ts`. It is intentionally **not** wired back into `devicesdk logs --tail` (that command still wants live push, which only the watcher WebSocket provides) - it exists for scripts, cron jobs, and the bundled MCP server's `devicesdk_device_logs` tool, which don't want to hold a socket open. If a corporate proxy blocks the WS, a caller can now poll this endpoint directly as a workaround; the CLI itself is unchanged. ### Firmware R2 upload fails with "The file … does not exist" in CI **Date**: 2026-05-29 diff --git a/apps/dashboard/src/components/DeviceLogs.vue b/apps/dashboard/src/components/DeviceLogs.vue index bc0ef9b5..d1fefccc 100644 --- a/apps/dashboard/src/components/DeviceLogs.vue +++ b/apps/dashboard/src/components/DeviceLogs.vue @@ -84,11 +84,12 @@ const props = defineProps<{ const $q = useQuasar(); const levelFilter = ref(null); -// Logs are now WS-only. The watcher WebSocket sends up to `backfillLimit` -// recent entries on connect (replay frames) followed by `history_complete`, -// then live events. The legacy HTTP `/logs` endpoint returns 410 — see -// apps/api/src/durableObjects/lib/device.ts `getLogs` for the full incident -// write-up. +// This panel stays WS-only for live tailing. The watcher WebSocket sends up +// to `backfillLimit` recent entries on connect (replay frames) followed by +// `history_complete`, then live events. A stateless paging GET also exists +// at /v1/projects/:projectId/devices/:deviceId/logs (see +// apps/server/src/endpoints/logs/listLogs.ts) for scripts and MCP tools that +// don't want to hold a socket open, but the dashboard has no need for it. const { streamedLogs, deviceStatus, diff --git a/apps/dashboard/src/composables/useDeviceStream.ts b/apps/dashboard/src/composables/useDeviceStream.ts index 7d3180ec..280c7f3a 100644 --- a/apps/dashboard/src/composables/useDeviceStream.ts +++ b/apps/dashboard/src/composables/useDeviceStream.ts @@ -18,8 +18,9 @@ export interface UseDeviceStreamOptions { * * Auto-reconnects on disconnection with exponential backoff. When * `backfillLimit` is provided, replay frames (history) and live events are - * delivered on the same socket - the dashboard's logs panel uses this to - * avoid the HTTP `/logs` endpoint, which was deprecated in May 2026. + * delivered on the same socket - the dashboard's logs panel uses this single + * socket for both history and live tailing instead of paging the HTTP + * `/logs` endpoint, which exists but only returns a point-in-time snapshot. * * Frame format: `{ event, data, replay? }` * - event "status" → connection state changes diff --git a/apps/dashboard/src/services/api.service.ts b/apps/dashboard/src/services/api.service.ts index 253e0e71..895837a8 100644 --- a/apps/dashboard/src/services/api.service.ts +++ b/apps/dashboard/src/services/api.service.ts @@ -500,7 +500,8 @@ export const logService = { * Pass `backfillLimit` to receive up to N recent log entries on connect as * `{ event: "log", data, replay: true }` frames followed by a single * `{ event: "history_complete" }` marker. Subsequent live events arrive on - * the same socket. This replaces the deprecated HTTP `/logs` endpoint. + * the same socket - the dashboard uses this instead of paging the HTTP + * `/logs` endpoint, which only returns a point-in-time snapshot. */ getWatchUrl( projectId: string, diff --git a/apps/server/src/endpoints/logs/listLogs.ts b/apps/server/src/endpoints/logs/listLogs.ts index 0d5557e3..d7d9778d 100644 --- a/apps/server/src/endpoints/logs/listLogs.ts +++ b/apps/server/src/endpoints/logs/listLogs.ts @@ -1,33 +1,47 @@ import { contentJson } from "chanfana"; import { z } from "zod"; import { BaseRoute } from "../../foundation/baseRoute"; +import { resolveProjectAndDevice } from "../../foundation/projectDeviceResolve"; import type { AppContext } from "../../types"; +const DEFAULT_LIMIT = 50; + +interface LogRow { + id: string; + level: string; + message: string; + created_at: number; +} + +function encodeCursor(createdAt: number, id: string): string { + return `${createdAt}:${id}`; +} + +function decodeCursor(raw: string): { createdAt: number; id: string } | null { + const idx = raw.indexOf(":"); + if (idx < 1) return null; + const createdAt = Number(raw.slice(0, idx)); + const id = raw.slice(idx + 1); + if (!Number.isFinite(createdAt) || !id) return null; + return { createdAt, id }; +} + /** * GET /v1/projects/:projectId/devices/:deviceId/logs * - * **Deprecated** as of May 2026 - always returns 410 Gone with a `Link` header - * pointing at the watcher WebSocket. The polling pattern this endpoint enabled - * burned the daily DO rows-read quota; see the comment block on - * `BaseDevice.getLogs` in `durableObjects/lib/device.ts` for the full incident - * write-up. - * - * Migrated callers (dashboard logs panel, CLI `logs`/`logs --tail`) connect to - * `/v1/projects/:projectId/devices/:deviceId/watch?backfillLimit=N` instead and - * receive history + live events on a single hibernating WebSocket. - * - * No D1 lookups are performed: the entire point of this fix is to avoid - * spending row-reads on a route that exists only to point clients at the - * replacement. The `Link` header lets a stale client follow the migration on - * its own; the rate limiter mounted on this path bounds the burn from any - * client that ignores the 410. + * Point-in-time page of persisted device logs (the `device_logs` table), + * newest first. This was a 410 in the Cloudflare era because each read burned + * the Durable Object's daily rows-read quota; now that the server owns its + * own SQLite storage there is no quota to protect, so plain paging is cheap. + * Complements rather than replaces the watcher WebSocket + * (`/watch?backfillLimit=N`): this is a stateless polling/paging API for + * scripts, cron jobs, and MCP tools that don't want to hold a socket open. */ export class ListLogs extends BaseRoute { public schema = { tags: ["Logs"], - summary: "Deprecated - use the watcher WebSocket", + summary: "List persisted device logs", operationId: "logs-list", - deprecated: true, request: { params: z.object({ projectId: z.string().min(1).max(36), @@ -40,32 +54,90 @@ export class ListLogs extends BaseRoute { }), }, responses: { - "410": { - description: "Endpoint deprecated - use the watcher WebSocket", + "200": { + description: "Page of device logs, newest first", ...contentJson( z.object({ - success: z.literal(false), - error: z.string(), - code: z.literal("LOGS_DEPRECATED"), + success: z.literal(true), + result: z.object({ + logs: z.array( + z.object({ + id: z.string(), + level: z.string(), + message: z.string(), + created_at: z.number(), + }), + ), + cursor: z + .string() + .nullable() + .describe("Pass as ?cursor= to fetch the next (older) page"), + }), }), ), }, + "400": { + description: "Invalid cursor", + }, + "404": { + description: "Project or device not found", + }, }, }; public async handle(c: AppContext) { const data = await this.getValidatedData(); const { projectId, deviceId } = data.params; + const { cursor, limit, level } = data.query; - const watchPath = `/v1/projects/${projectId}/devices/${deviceId}/watch`; - return c.json( - { - success: false, - error: `Endpoint deprecated. Use the watcher WebSocket at ${watchPath}?backfillLimit=N`, - code: "LOGS_DEPRECATED", - }, - 410, - { Link: `<${watchPath}>; rel="alternate"` }, - ); + const resolved = await resolveProjectAndDevice(c, projectId, deviceId); + if (resolved instanceof Response) return resolved; + const { device } = resolved; + + let before: { createdAt: number; id: string } | null = null; + if (cursor !== undefined) { + before = decodeCursor(cursor); + if (!before) { + return c.json({ success: false, error: "Invalid cursor" }, 400); + } + } + + const pageSize = limit ?? DEFAULT_LIMIT; + const conditions = ["device_id = ?1"]; + const params: (string | number)[] = [device.id]; + let idx = 2; + if (level) { + conditions.push(`level = ?${idx}`); + params.push(level); + idx++; + } + if (before) { + conditions.push( + `(created_at < ?${idx} OR (created_at = ?${idx} AND id < ?${idx + 1}))`, + ); + params.push(before.createdAt, before.id); + idx += 2; + } + params.push(pageSize); + + const rows = c + .get("qb") + .db.query( + `SELECT id, level, message, created_at FROM device_logs + WHERE ${conditions.join(" AND ")} + ORDER BY created_at DESC, id DESC LIMIT ?${idx}`, + ) + .all(...params) as LogRow[]; + + const last = rows[rows.length - 1]; + const nextCursor = + rows.length === pageSize && last + ? encodeCursor(last.created_at, last.id) + : null; + + return c.json({ + success: true, + result: { logs: rows, cursor: nextCursor }, + }); } } diff --git a/apps/server/src/mcp/tools.ts b/apps/server/src/mcp/tools.ts index fe0882e2..6d6bd8f6 100644 --- a/apps/server/src/mcp/tools.ts +++ b/apps/server/src/mcp/tools.ts @@ -198,11 +198,10 @@ export function registerTools(server: McpServer, deps: ToolDeps): void { { title: "Device logs", description: - "Fetch device logs. NOTE: as of the current server version this REST endpoint " + - "is deprecated and always returns a 410 response pointing at the live watcher " + - "WebSocket (/v1/projects/:projectId/devices/:deviceId/watch) instead of a log " + - "page - there is no point-in-time log snapshot API today. Kept as a tool for " + - "API symmetry; expect isError:true with a LOGS_DEPRECATED code.", + "Fetch a page of persisted device logs, newest first. Optionally filter by " + + "level and page backwards in time with the returned cursor. For live tailing, " + + "use the watcher WebSocket (/v1/projects/:projectId/devices/:deviceId/watch) " + + "instead - this tool is a point-in-time snapshot.", inputSchema: { projectId: projectIdParam, deviceId: deviceIdParam, diff --git a/apps/server/tests/e2e/logs.test.ts b/apps/server/tests/e2e/logs.test.ts index dfcafe92..3a724b7b 100644 --- a/apps/server/tests/e2e/logs.test.ts +++ b/apps/server/tests/e2e/logs.test.ts @@ -1,12 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { TestServer } from "../harness"; -// NOTE: GET /v1/projects/:projectId/devices/:deviceId/logs is **deprecated** -// (see src/endpoints/logs/listLogs.ts). It now always returns 410 Gone with a -// `Link` header pointing at the watcher WebSocket and performs NO database -// lookups - clients migrate to `/watch?backfillLimit=N`. These tests pin that -// contract so a future "un-deprecation" can't silently regress callers. - let srv: TestServer; beforeAll(async () => { @@ -15,75 +9,160 @@ beforeAll(async () => { afterAll(() => srv.stop()); -interface DeprecatedBody { - success: false; - error: string; - code: "LOGS_DEPRECATED"; +interface LogsBody { + success: true; + result: { + logs: { id: string; level: string; message: string; created_at: number }[]; + cursor: string | null; + }; +} + +function seedLogs( + deviceId: string, + entries: { level: string; message: string; created_at: number }[], +) { + for (const entry of entries) { + srv.db + .query( + "INSERT INTO device_logs (id, device_id, level, message, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .run( + crypto.randomUUID(), + deviceId, + entry.level, + entry.message, + entry.created_at, + ); + } } -describe("deprecated logs endpoint", () => { - test("returns 410 Gone with the LOGS_DEPRECATED code and Link header", async () => { +describe("logs endpoint", () => { + test("returns an empty page when no logs exist", async () => { const { auth, projectSlug, deviceSlug } = await srv.scaffold({ - projectSlug: "logs-gone", + projectSlug: "logs-empty", deviceSlug: "dev", }); const res = await srv.get( `/v1/projects/${projectSlug}/devices/${deviceSlug}/logs`, { token: auth.token }, ); - expect(res.status).toBe(410); - const body = res.body as DeprecatedBody; - expect(body.success).toBe(false); - expect(body.code).toBe("LOGS_DEPRECATED"); - expect(body.error).toContain("watcher WebSocket"); - - const link = res.headers.get("Link"); - expect(link).toBeTruthy(); - expect(link).toContain( - `/v1/projects/${projectSlug}/devices/${deviceSlug}/watch`, - ); - expect(link).toContain('rel="alternate"'); + expect(res.status).toBe(200); + const body = res.body as LogsBody; + expect(body.success).toBe(true); + expect(body.result.logs).toEqual([]); + expect(body.result.cursor).toBeNull(); }); - test("410 is returned even when device_logs rows exist (no DB lookup)", async () => { + test("returns rows newest first", async () => { const { auth, projectSlug, deviceSlug, deviceId } = await srv.scaffold({ - projectSlug: "logs-rows", + projectSlug: "logs-order", deviceSlug: "dev", }); - // Seed real rows; the deprecated endpoint must ignore them entirely. const now = Date.now(); - for (let i = 0; i < 3; i++) { - srv.db - .query( - "INSERT INTO device_logs (id, device_id, level, message, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - ) - .run(crypto.randomUUID(), deviceId, "info", `message ${i}`, now + i); - } + seedLogs(deviceId, [ + { level: "info", message: "first", created_at: now }, + { level: "info", message: "second", created_at: now + 1 }, + { level: "info", message: "third", created_at: now + 2 }, + ]); + const res = await srv.get( `/v1/projects/${projectSlug}/devices/${deviceSlug}/logs`, { token: auth.token }, ); - expect(res.status).toBe(410); - expect((res.body as DeprecatedBody).code).toBe("LOGS_DEPRECATED"); + expect(res.status).toBe(200); + const body = res.body as LogsBody; + expect(body.result.logs.map((l) => l.message)).toEqual([ + "third", + "second", + "first", + ]); + expect(body.result.cursor).toBeNull(); }); - test("query params (limit/level/cursor) are accepted but still 410", async () => { - const { auth, projectSlug, deviceSlug } = await srv.scaffold({ - projectSlug: "logs-query", + test("filters by level", async () => { + const { auth, projectSlug, deviceSlug, deviceId } = await srv.scaffold({ + projectSlug: "logs-level", deviceSlug: "dev", }); + const now = Date.now(); + seedLogs(deviceId, [ + { level: "info", message: "info-1", created_at: now }, + { level: "error", message: "error-1", created_at: now + 1 }, + { level: "info", message: "info-2", created_at: now + 2 }, + ]); + const res = await srv.get( + `/v1/projects/${projectSlug}/devices/${deviceSlug}/logs`, + { token: auth.token, query: { level: "error" } }, + ); + expect(res.status).toBe(200); + const body = res.body as LogsBody; + expect(body.result.logs).toHaveLength(1); + expect(body.result.logs[0]?.message).toBe("error-1"); + }); + + test("paginates with a cursor", async () => { + const { auth, projectSlug, deviceSlug, deviceId } = await srv.scaffold({ + projectSlug: "logs-cursor", + deviceSlug: "dev", + }); + const now = Date.now(); + seedLogs( + deviceId, + Array.from({ length: 5 }, (_, i) => ({ + level: "info", + message: `msg-${i}`, + created_at: now + i, + })), + ); + + const page1 = await srv.get( + `/v1/projects/${projectSlug}/devices/${deviceSlug}/logs`, + { token: auth.token, query: { limit: 2 } }, + ); + expect(page1.status).toBe(200); + const body1 = page1.body as LogsBody; + expect(body1.result.logs.map((l) => l.message)).toEqual(["msg-4", "msg-3"]); + expect(body1.result.cursor).toBeTruthy(); + + const page2 = await srv.get( `/v1/projects/${projectSlug}/devices/${deviceSlug}/logs`, { token: auth.token, - query: { limit: 25, level: "error", cursor: "abc" }, + query: { limit: 2, cursor: body1.result.cursor as string }, }, ); - expect(res.status).toBe(410); - expect((res.body as DeprecatedBody).code).toBe("LOGS_DEPRECATED"); + expect(page2.status).toBe(200); + const body2 = page2.body as LogsBody; + expect(body2.result.logs.map((l) => l.message)).toEqual(["msg-2", "msg-1"]); + expect(body2.result.cursor).toBeTruthy(); + + const page3 = await srv.get( + `/v1/projects/${projectSlug}/devices/${deviceSlug}/logs`, + { + token: auth.token, + query: { limit: 2, cursor: body2.result.cursor as string }, + }, + ); + expect(page3.status).toBe(200); + const body3 = page3.body as LogsBody; + expect(body3.result.logs.map((l) => l.message)).toEqual(["msg-0"]); + expect(body3.result.cursor).toBeNull(); + }); + + test("rejects a malformed cursor with 400", async () => { + const { auth, projectSlug, deviceSlug } = await srv.scaffold({ + projectSlug: "logs-bad-cursor", + deviceSlug: "dev", + }); + const res = await srv.get( + `/v1/projects/${projectSlug}/devices/${deviceSlug}/logs`, + { token: auth.token, query: { cursor: "not-a-cursor" } }, + ); + expect(res.status).toBe(400); }); - test("invalid query params are rejected by validation before the 410", async () => { + test("invalid query params are rejected by validation", async () => { const { auth, projectSlug, deviceSlug } = await srv.scaffold({ projectSlug: "logs-bad", deviceSlug: "dev", @@ -96,6 +175,18 @@ describe("deprecated logs endpoint", () => { expect(res.status).toBe(400); }); + test("404s for a device outside the caller's project", async () => { + const { auth, projectSlug } = await srv.scaffold({ + projectSlug: "logs-404", + deviceSlug: "dev", + }); + const res = await srv.get( + `/v1/projects/${projectSlug}/devices/does-not-exist/logs`, + { token: auth.token }, + ); + expect(res.status).toBe(404); + }); + test("unauthenticated request is rejected before reaching the handler", async () => { const { projectSlug, deviceSlug } = await srv.scaffold({ projectSlug: "logs-unauth", diff --git a/docs/public/mcp.md b/docs/public/mcp.md index f73933d7..0b229411 100644 --- a/docs/public/mcp.md +++ b/docs/public/mcp.md @@ -126,7 +126,7 @@ If your host doesn't support OAuth, or you want a static credential (CI, headles | `devicesdk_upload_script` | Upload a new script version and deploy it immediately. | | `devicesdk_deploy_version` | Activate a previously-uploaded version (rollback or promote). | | `devicesdk_send_command` | Send a hardware command (GPIO, I2C, SPI, UART, display, reboot, ...) to a connected device and wait for its response. | -| `devicesdk_device_logs` | Point-in-time device logs. Currently always returns a deprecation notice pointing at the live watcher WebSocket - use the dashboard's live log view instead until a snapshot API exists. | +| `devicesdk_device_logs` | Point-in-time page of persisted device logs, newest first, with cursor pagination and level filtering. For live tailing, use the dashboard's log view (backed by the watcher WebSocket) instead. | | `devicesdk_docs_search` | Full-text search over an **offline** copy of these docs matching your server's version - no internet call, so results can lag the live site until your server image is updated. | Every tool returns the same `{ success, result | error, code? }` shape the REST API does; the MCP result's `isError` mirrors `success`. diff --git a/plans/014-entity-state-history.md b/plans/014-entity-state-history.md index 9cd7cb2c..7b6fcdcc 100644 --- a/plans/014-entity-state-history.md +++ b/plans/014-entity-state-history.md @@ -173,8 +173,9 @@ Repo conventions that apply: deliberate non-goal. Only user `emitState` persists. - `packages/core` - the `emitState` contract and types are unchanged. - `packages/cli` - no CLI changes; you only run its tests as a regression gate. -- `apps/server/src/endpoints/logs/` - the deprecated 410 endpoint is - unrelated; do not "un-deprecate" it or model the history endpoint on it. +- `apps/server/src/endpoints/logs/` - the log-only paging endpoint is + unrelated to entity state; do not extend it to cover entity history or + model the new history endpoint on it. - Firmware directories, `apps/website`, `apps/simulation`. - Any change to the shape of existing watch frames (`log`, `status`, `history_complete`) or existing REST responses. From 8cda7b5d3abbb7a540cf3f261aa1677de975ca9d Mon Sep 17 00:00:00 2001 From: Gabriel Massadas <5445926+G4brym@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:07:49 +0000 Subject: [PATCH 16/16] fix(server): thread docsIndexPath through MCP tool deps instead of re-reading process.env devicesdk_docs_search called searchDocs(), which lazily loaded its own config via a bare loadConfig() (defaulting to process.env). The e2e test harness sets DOCS_INDEX_PATH via an object passed to loadConfig(overrides), never touching process.env, so this second, disconnected config read always resolved to the default dist/docs-index.sqlite - present locally only by accident of a stale build artifact, absent in CI (which never builds apps/server), causing devicesdk_docs_search to deterministically fail with docs_index_missing in CI while appearing to pass locally. Fix: pass c.env.config.docsIndexPath through ToolDeps into searchDocs instead of having docsSearch.ts re-derive config independently - one source of truth for the path, matching how every other MCP tool already gets its context via the loopback. searchDocs() now takes the path as a parameter and caches its DB handle keyed by path, so multiple servers in one process (e2e test harness) get independent handles instead of racing over a single global cache. Co-Authored-By: Claude Sonnet 5 --- apps/server/src/mcp/docsSearch.ts | 37 +++++++++++++++-------- apps/server/src/mcp/route.ts | 5 ++- apps/server/src/mcp/tools.ts | 6 ++-- apps/server/tests/unit/docs-index.test.ts | 33 +++++++------------- 4 files changed, 43 insertions(+), 38 deletions(-) diff --git a/apps/server/src/mcp/docsSearch.ts b/apps/server/src/mcp/docsSearch.ts index 75f8c87b..373d3326 100644 --- a/apps/server/src/mcp/docsSearch.ts +++ b/apps/server/src/mcp/docsSearch.ts @@ -1,6 +1,5 @@ import { Database } from "bun:sqlite"; import { existsSync } from "node:fs"; -import { loadConfig } from "../config"; export interface DocsSearchRow { path: string; @@ -15,19 +14,27 @@ export type DocsSearchResult = const SITE_URL = "https://devicesdk.com"; -let cachedDb: Database | null | undefined; +let cachedPath: string | undefined; +let cachedDb: Database | null = null; -/** Lazily opens the read-only docs index on first query, caching the handle. */ -function getDb(): Database | null { - if (cachedDb !== undefined) return cachedDb; +/** + * Lazily opens the read-only docs index, caching the handle for as long as + * `docsIndexPath` doesn't change. Keyed by path (rather than a bare boolean) + * so multiple servers in one process - e.g. the e2e test harness, which boots + * a fresh server with its own DOCS_INDEX_PATH per test file - each get their + * own handle instead of racing over a single global cache. + */ +function getDb(docsIndexPath: string): Database | null { + if (cachedPath === docsIndexPath) return cachedDb; - const path = loadConfig().docsIndexPath; - if (!existsSync(path)) { + cachedDb?.close(); + cachedPath = docsIndexPath; + if (!existsSync(docsIndexPath)) { cachedDb = null; return cachedDb; } try { - cachedDb = new Database(path, { readonly: true }); + cachedDb = new Database(docsIndexPath, { readonly: true }); } catch { cachedDb = null; } @@ -36,12 +43,13 @@ function getDb(): Database | null { /** * Test-only: drops the cached DB handle so a subsequent `searchDocs` call - * re-reads `DOCS_INDEX_PATH` from config instead of reusing whatever was - * cached by an earlier call (mirrors foundation/logger.ts's `resetLogger`). + * reopens `docsIndexPath` instead of reusing whatever was cached by an + * earlier call (mirrors foundation/logger.ts's `resetLogger`). */ export function resetDocsSearchCache(): void { cachedDb?.close(); - cachedDb = undefined; + cachedDb = null; + cachedPath = undefined; } /** @@ -69,8 +77,11 @@ const SEARCH_SQL = * structured `{ success: false }` result, matching the same convention every * other devicesdk_* tool uses. */ -export function searchDocs(query: string): DocsSearchResult { - const db = getDb(); +export function searchDocs( + query: string, + docsIndexPath: string, +): DocsSearchResult { + const db = getDb(docsIndexPath); if (!db) { return { success: false, diff --git a/apps/server/src/mcp/route.ts b/apps/server/src/mcp/route.ts index e18a28e7..c85194bc 100644 --- a/apps/server/src/mcp/route.ts +++ b/apps/server/src/mcp/route.ts @@ -69,7 +69,10 @@ function buildLoopback(appLike: AppRequester, c: AppContext): LoopbackFn { export function createMcpPostRoute(appLike: AppRequester) { return async (c: AppContext) => { const loopback = buildLoopback(appLike, c); - const server = buildMcpServer({ loopback }); + const server = buildMcpServer({ + loopback, + docsIndexPath: c.env.config.docsIndexPath, + }); const transport = new StreamableHTTPTransport({ // Stateless mode (MCP SDK): no session id is generated or validated. sessionIdGenerator: undefined, diff --git a/apps/server/src/mcp/tools.ts b/apps/server/src/mcp/tools.ts index 6d6bd8f6..338610a9 100644 --- a/apps/server/src/mcp/tools.ts +++ b/apps/server/src/mcp/tools.ts @@ -27,6 +27,8 @@ export type LoopbackFn = ( export interface ToolDeps { loopback: LoopbackFn; + /** Path to the build-time SQLite FTS5 docs index (see ../config.ts). */ + docsIndexPath: string; } interface ApiEnvelope { @@ -115,7 +117,7 @@ const COMMAND_TYPES = [ /** Registers every devicesdk_* tool on `server`, wired to the given loopback. */ export function registerTools(server: McpServer, deps: ToolDeps): void { - const { loopback } = deps; + const { loopback, docsIndexPath } = deps; // --- Read-only tools ----------------------------------------------- @@ -306,7 +308,7 @@ export function registerTools(server: McpServer, deps: ToolDeps): void { annotations: { readOnlyHint: true, title: "Search DeviceSDK docs" }, }, async ({ query }) => { - const result = searchDocs(query); + const result = searchDocs(query, docsIndexPath); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: !result.success, diff --git a/apps/server/tests/unit/docs-index.test.ts b/apps/server/tests/unit/docs-index.test.ts index 372d0383..0a664456 100644 --- a/apps/server/tests/unit/docs-index.test.ts +++ b/apps/server/tests/unit/docs-index.test.ts @@ -72,13 +72,6 @@ Welcome to the fruit documentation. See the guides for apples and bananas. `indexer failed (exit ${proc.exitCode}): ${proc.stderr.toString()}`, ); } - - // Point the tool under test (devicesdk_docs_search) at this fixture index. - // resetDocsSearchCache() guarantees this test file sees its own fixture - // regardless of whatever a previous test (in this file or, if bun:test - // ever shares a module registry across files, another one) already cached. - process.env.DOCS_INDEX_PATH = indexPath; - resetDocsSearchCache(); }); afterAll(() => { @@ -120,7 +113,7 @@ describe("build-docs-index: indexing", () => { describe("devicesdk_docs_search (searchDocs) against the fixture index", () => { test("BM25 ranks the banana-focused page above the apple-focused page", () => { - const result = searchDocs("banana"); + const result = searchDocs("banana", indexPath); expect(result.success).toBe(true); if (!result.success) return; expect(result.result.matches.length).toBeGreaterThan(0); @@ -128,7 +121,7 @@ describe("devicesdk_docs_search (searchDocs) against the fixture index", () => { }); test("returns a snippet and a devicesdk.com URL for each match", () => { - const result = searchDocs("banana"); + const result = searchDocs("banana", indexPath); expect(result.success).toBe(true); if (!result.success) return; const first = result.result.matches[0]; @@ -138,8 +131,10 @@ describe("devicesdk_docs_search (searchDocs) against the fixture index", () => { }); test("a query full of FTS5 operator syntax never throws - result set or empty", () => { - expect(() => searchDocs('AND ( * NEAR "unterminated')).not.toThrow(); - const result = searchDocs('AND ( * NEAR "unterminated'); + expect(() => + searchDocs('AND ( * NEAR "unterminated', indexPath), + ).not.toThrow(); + const result = searchDocs('AND ( * NEAR "unterminated', indexPath); expect(result.success).toBe(true); if (result.success) { expect(Array.isArray(result.result.matches)).toBe(true); @@ -147,23 +142,17 @@ describe("devicesdk_docs_search (searchDocs) against the fixture index", () => { }); test("empty query is a structured failure, not a throw", () => { - const result = searchDocs(" "); + const result = searchDocs(" ", indexPath); expect(result.success).toBe(false); }); }); describe("devicesdk_docs_search: missing index file", () => { - afterAll(() => { - // Restore the fixture index for any test file sharing this process. - process.env.DOCS_INDEX_PATH = indexPath; - resetDocsSearchCache(); - }); - test("reports docs_index_missing instead of throwing", () => { - process.env.DOCS_INDEX_PATH = join(fixtureDir, "does-not-exist.sqlite"); - resetDocsSearchCache(); - - const result = searchDocs("banana"); + const result = searchDocs( + "banana", + join(fixtureDir, "does-not-exist.sqlite"), + ); expect(result.success).toBe(false); if (!result.success) { expect(result.code).toBe("docs_index_missing");