diff --git a/README.md b/README.md index f3d19be..2e45b0d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,11 @@ noticed path @sarahml ## Add to your AI coding agent -The MCP server exposes two tools — `search_network` and `get_connection_path` — to any client that speaks Model Context Protocol. Pick your client below. +The MCP server exposes two meta-tools — **`search`** and **`execute`** — backed by ~50 noticed capabilities: developer-network search and connection paths, mission and goal tracking, a PRM (people-relationship-management) board, a virtual filesystem for agent workspace files, persistent memory, web search, scheduled crons, and more. Same surface the noticed web and Telegram agents use. Chat-only capabilities (in-chat messaging, referral invites, the Cursor Cloud bridge) are filtered server-side. + +> Upgrading from 0.2.x? The tool surface changed: clients that called `search_network` / `get_connection_path` directly should now call `search` (to discover the capability) followed by `execute { capability: "search_network", args: { query: "…" } }`. MCP-aware LLMs handle this discovery automatically. + +Pick your client below. ### Claude Code diff --git a/__tests__/api-client.test.ts b/__tests__/api-client.test.ts index af145c5..c039648 100644 --- a/__tests__/api-client.test.ts +++ b/__tests__/api-client.test.ts @@ -244,3 +244,74 @@ describe("NoticedApiClient.path", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe("NoticedApiClient.capabilitySearch / capabilityExecute", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function makeClient(): NoticedApiClient { + return new NoticedApiClient({ + baseUrl: "https://noticed.so", + apiKey: "nk_live_test", + }); + } + + it("POSTs capabilitySearch to /api/agent/capabilities/search with bearer", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = makeClient(); + await client.capabilitySearch({ query: "missions", limit: 5 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(String(url)).toBe("https://noticed.so/api/agent/capabilities/search"); + const i = init as RequestInit; + expect(i.method).toBe("POST"); + expect((i.headers as Record).Authorization).toBe( + "Bearer nk_live_test", + ); + expect((i.headers as Record)["Content-Type"]).toBe( + "application/json", + ); + expect(JSON.parse(String(i.body))).toEqual({ query: "missions", limit: 5 }); + }); + + it("POSTs capabilityExecute to /api/agent/capabilities/execute", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ result: { ok: true } }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = makeClient(); + await client.capabilityExecute({ + capability: "list_missions", + args: {}, + }); + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(String(url)).toBe("https://noticed.so/api/agent/capabilities/execute"); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ + capability: "list_missions", + args: {}, + }); + }); + + it("throws on non-2xx response", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "{\"error\":\"Unauthorized\"}", + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = makeClient(); + await expect(client.capabilitySearch({})).rejects.toThrow(/401/); + }); +}); diff --git a/__tests__/mcp-server.test.ts b/__tests__/mcp-server.test.ts index d3c431c..c986fbc 100644 --- a/__tests__/mcp-server.test.ts +++ b/__tests__/mcp-server.test.ts @@ -1,100 +1,87 @@ import { describe, it, expect } from "vitest"; import { - SearchNetworkArgsSchema, - GetConnectionPathArgsSchema, + SearchArgsSchema, + ExecuteArgsSchema, } from "../src/mcp-server.js"; -describe("MCP Input Validation — SearchNetworkArgs", () => { - it("accepts valid search args", () => { - const result = SearchNetworkArgsSchema.safeParse({ query: "AI engineers" }); +describe("MCP Input Validation — SearchArgs", () => { + it("accepts an empty object and defaults limit to 50", () => { + const result = SearchArgsSchema.safeParse({}); expect(result.success).toBe(true); if (result.success) { - expect(result.data.query).toBe("AI engineers"); - expect(result.data.limit).toBe(25); - expect(result.data.offset).toBe(0); - expect(result.data.include_paths).toBe(true); + expect(result.data.limit).toBe(50); + expect(result.data.query).toBeUndefined(); + expect(result.data.category).toBeUndefined(); } }); - it("accepts all optional parameters", () => { - const result = SearchNetworkArgsSchema.safeParse({ - query: "react", + it("accepts query + category + limit", () => { + const result = SearchArgsSchema.safeParse({ + query: "missions", + category: "missions", limit: 10, - offset: 5, - source: "github", - include_paths: false, }); expect(result.success).toBe(true); if (result.success) { + expect(result.data.query).toBe("missions"); + expect(result.data.category).toBe("missions"); expect(result.data.limit).toBe(10); - expect(result.data.offset).toBe(5); - expect(result.data.source).toBe("github"); - expect(result.data.include_paths).toBe(false); } }); - it("rejects empty query", () => { - const result = SearchNetworkArgsSchema.safeParse({ query: "" }); - expect(result.success).toBe(false); - }); - - it("rejects missing query", () => { - const result = SearchNetworkArgsSchema.safeParse({}); - expect(result.success).toBe(false); - }); - it("rejects limit > 50", () => { - const result = SearchNetworkArgsSchema.safeParse({ query: "test", limit: 100 }); + const result = SearchArgsSchema.safeParse({ limit: 100 }); expect(result.success).toBe(false); }); it("rejects limit < 1", () => { - const result = SearchNetworkArgsSchema.safeParse({ query: "test", limit: 0 }); + const result = SearchArgsSchema.safeParse({ limit: 0 }); expect(result.success).toBe(false); }); - it("rejects negative offset", () => { - const result = SearchNetworkArgsSchema.safeParse({ query: "test", offset: -1 }); + it("rejects non-integer limit", () => { + const result = SearchArgsSchema.safeParse({ limit: 12.5 }); expect(result.success).toBe(false); }); +}); - it("rejects invalid source", () => { - const result = SearchNetworkArgsSchema.safeParse({ query: "test", source: "twitter" }); - expect(result.success).toBe(false); - }); - - it("accepts linkedin source", () => { - const result = SearchNetworkArgsSchema.safeParse({ query: "test", source: "linkedin" }); - expect(result.success).toBe(true); - }); - - it("accepts a sort directive", () => { - const result = SearchNetworkArgsSchema.safeParse({ query: "test", sort: "name:asc" }); +describe("MCP Input Validation — ExecuteArgs", () => { + it("accepts a capability name with no args", () => { + const result = ExecuteArgsSchema.safeParse({ capability: "list_missions" }); expect(result.success).toBe(true); - if (result.success) expect(result.data.sort).toBe("name:asc"); + if (result.success) { + expect(result.data.capability).toBe("list_missions"); + expect(result.data.args).toBeUndefined(); + } }); -}); -describe("MCP Input Validation — GetConnectionPathArgs", () => { - it("accepts a natural-language query (we'll resolve via search)", () => { - const result = GetConnectionPathArgsSchema.safeParse({ query: "Sarah Chen" }); + it("accepts a capability name with an args object", () => { + const result = ExecuteArgsSchema.safeParse({ + capability: "get_person_dossier", + args: { github_user_id: 12345 }, + }); expect(result.success).toBe(true); - if (result.success) expect(result.data.query).toBe("Sarah Chen"); + if (result.success) { + expect(result.data.args).toEqual({ github_user_id: 12345 }); + } }); - it("accepts an explicit github_user_id target without a query", () => { - const result = GetConnectionPathArgsSchema.safeParse({ github_user_id: 12345 }); - expect(result.success).toBe(true); + it("rejects missing capability", () => { + const result = ExecuteArgsSchema.safeParse({}); + expect(result.success).toBe(false); }); - it("accepts an explicit linkedin_username target without a query", () => { - const result = GetConnectionPathArgsSchema.safeParse({ linkedin_username: "sarah-chen" }); - expect(result.success).toBe(true); + it("rejects empty capability", () => { + const result = ExecuteArgsSchema.safeParse({ capability: "" }); + expect(result.success).toBe(false); }); - it("rejects when neither query nor explicit target is provided", () => { - const result = GetConnectionPathArgsSchema.safeParse({}); + it("rejects args that is not an object", () => { + const result = ExecuteArgsSchema.safeParse({ + capability: "list_missions", + args: "not-an-object", + }); expect(result.success).toBe(false); }); }); diff --git a/package.json b/package.json index 839a6cd..fd4dc62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@noticed/cli", - "version": "0.2.0", + "version": "0.3.0", "description": "CLI and MCP server for noticed — search your developer network, trace connections, find the shortest path to anyone through GitHub and LinkedIn.", "keywords": [ "noticed", diff --git a/server.json b/server.json index 2009405..986549c 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.noticedso/cli", "description": "Search your developer network and trace connection paths via GitHub & LinkedIn.", - "version": "0.2.0", + "version": "0.3.0", "repository": { "url": "https://github.com/noticedso/cli", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@noticed/cli", - "version": "0.2.0", + "version": "0.3.0", "transport": { "type": "stdio" }, "runtimeHint": "npx", "runtimeArguments": [ diff --git a/src/api-client.ts b/src/api-client.ts index 0198a2d..4297a0d 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -179,6 +179,53 @@ export class NoticedApiClient { return parsed.path; } + /** + * Bridge to the agent's capability registry. `search` returns chat-safe + * capabilities (with their parameter schemas) matching an optional query. + * The 55-capability surface is the same one the web and Telegram noticed + * agents dispatch through; nine chat-only capabilities are filtered server-side. + */ + async capabilitySearch(args: { + query?: string; + category?: string; + limit?: number; + }): Promise { + return this.postJson("/api/agent/capabilities/search", args); + } + + /** + * Invoke a capability by exact name. Use `capabilitySearch` first to find + * the name and required arguments. Errors are surfaced as HTTP 200 bodies + * with an `error` field (e.g. `unavailable_from_mcp`, `unknown_capability`, + * `validation_failed`, `execution_failed`) per the MCP error-channel + * convention. + */ + async capabilityExecute(args: { + capability: string; + args?: Record; + }): Promise { + return this.postJson("/api/agent/capabilities/execute", args); + } + + private async postJson(path: string, body: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `API error ${res.status}: ${res.statusText}${text ? ` — ${text}` : ""}`, + ); + } + return res.json(); + } + async hydrate(hits: SearchHit[]): Promise { const url = new URL(`${this.baseUrl}/api/search/hydrate`); const res = await fetch(url.toString(), { diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 74af755..fec8a4d 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -2,13 +2,17 @@ * MCP (Model Context Protocol) server for noticed. * * Implements the MCP specification over stdio (JSON-RPC 2.0, newline-delimited). - * Exposes noticed search capabilities as tools that AI agents can call. + * Exposes two meta-tools (`search` + `execute`) backed by the same capability + * registry that powers the noticed web and Telegram agents. A nine-name + * server-side denylist filters chat-only capabilities (message, referrals, + * cursor-cloud, etc.) so MCP/CLI clients only see capabilities that work + * outside a chat context. * * Specification: https://modelcontextprotocol.io/specification * * Tools provided: - * - search_network: Search developers in the user's network - * - get_connection_path: Find the shortest path between users + * - search: Discover capabilities by query/category, returns names + schemas + * - execute: Run a named capability with arguments * * Usage: * noticed mcp # Start server (stdio) @@ -16,7 +20,7 @@ */ import { z } from "zod"; -import { createClientFromEnv, type SearchResponse, type ConnectionPath } from "./api-client.js"; +import { createClientFromEnv } from "./api-client.js"; import { VERSION } from "./version.js"; import * as readline from "node:readline"; @@ -24,25 +28,16 @@ import * as readline from "node:readline"; // Zod schemas for tool input validation (single source of truth) // --------------------------------------------------------------------------- -export const SearchNetworkArgsSchema = z.object({ - query: z.string().min(1, "query is required and must be non-empty"), - limit: z.number().int().min(1).max(50).default(25), - offset: z.number().int().min(0).default(0), - source: z.enum(["github", "linkedin"]).optional(), - sort: z.string().optional(), - include_paths: z.boolean().default(true), +export const SearchArgsSchema = z.object({ + query: z.string().optional(), + category: z.string().optional(), + limit: z.number().int().min(1).max(50).default(50), }); -export const GetConnectionPathArgsSchema = z - .object({ - query: z.string().min(1).optional(), - github_user_id: z.number().int().positive().optional(), - linkedin_username: z.string().min(1).optional(), - }) - .refine( - (v) => !!(v.query || v.github_user_id || v.linkedin_username), - { message: "Provide query, github_user_id, or linkedin_username" }, - ); +export const ExecuteArgsSchema = z.object({ + capability: z.string().min(1, "capability is required"), + args: z.record(z.unknown()).optional(), +}); // --------------------------------------------------------------------------- // Types @@ -81,77 +76,53 @@ const SERVER_CAPABILITIES = { const TOOLS = [ { - name: "search_network", + name: "search", description: - "Search the user's developer network across GitHub collaborators and LinkedIn connections. " + - "Supports natural language queries: names, companies, skills, topics, job titles. " + - "Returns matching profiles with source attribution and connection paths showing how the user is connected to each result.", + "Discover noticed capabilities by keyword and optional category. Returns names, descriptions, categories, and JSON parameter schemas. Call with no arguments to list everything. This is the source of truth for which capabilities exist — do not assume a fixed list.", inputSchema: { type: "object" as const, properties: { query: { type: "string", description: - "Search query — a name, company, skill, topic, or natural language description (e.g., 'AI engineers at Google', 'react developers', 'sarahml')", - }, - limit: { - type: "number", - description: "Maximum number of results to return (1-50, default 25)", - minimum: 1, - maximum: 50, - default: 25, - }, - offset: { - type: "number", - description: "Pagination offset (default 0)", - minimum: 0, - default: 0, - }, - source: { - type: "string", - enum: ["github", "linkedin"], - description: "Filter results by source (omit for all sources)", + "Optional keyword to filter capabilities by name, description, or category.", }, - sort: { + category: { type: "string", description: - "Sort directive in the form 'column:direction' (e.g. 'name:asc', 'company:desc')", + "Optional exact category: search, memory, scheduling, workspace, onboarding, missions, sessions, prm, network, custom.", }, - include_paths: { - type: "boolean", + limit: { + type: "number", description: - "Include connection paths showing how you're connected to each result (default true)", - default: true, + "Maximum results (1-50, default 50 — returns the full chat-safe registry for an unfiltered call).", + minimum: 1, + maximum: 50, + default: 50, }, }, - required: ["query"], additionalProperties: false, }, }, { - name: "get_connection_path", + name: "execute", description: - "Find the shortest connection path from the current user to a target person. " + - "Provide either a natural-language `query` (we search and use the top match) " + - "or an explicit `github_user_id` / `linkedin_username`. " + - "Returns the path chain with profiles at each hop and the edge type (GitHub collab or LinkedIn connection).", + "Run a capability by exact name. Use `search` first to find the name and required arguments. Pass capability arguments in the `args` object (e.g. args: { mission_id: '...' }).", inputSchema: { type: "object" as const, properties: { - query: { + capability: { type: "string", - description: - "Name or identifier of the person to find a path to (e.g., 'Sarah Chen', '@sarahml', 'CTO at Vercel')", - }, - github_user_id: { - type: "number", - description: "Numeric GitHub user id of the target (preferred when known)", + description: "Capability name from search results.", }, - linkedin_username: { - type: "string", - description: "LinkedIn vanity username of the target", + args: { + type: "object", + description: + "Arguments object matching the capability's parameter schema.", + additionalProperties: true, }, }, + required: ["capability"], additionalProperties: false, }, }, @@ -317,11 +288,11 @@ async function handleToolCall( try { switch (toolName) { - case "search_network": - return await handleSearchNetwork(id, rawArgs); + case "search": + return await handleSearch(id, rawArgs); - case "get_connection_path": - return await handleGetConnectionPath(id, rawArgs); + case "execute": + return await handleExecute(id, rawArgs); default: // JSON-RPC 2.0 §5.1: Invalid params (unknown tool) @@ -344,14 +315,15 @@ async function handleToolCall( } } -async function handleSearchNetwork( +async function handleSearch( id: string | number | null, rawArgs: Record, ): Promise { - // Validate input against Zod schema - const parsed = SearchNetworkArgsSchema.safeParse(rawArgs); + const parsed = SearchArgsSchema.safeParse(rawArgs); if (!parsed.success) { - const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); + const issues = parsed.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; "); return { jsonrpc: "2.0", id, @@ -362,86 +334,26 @@ async function handleSearchNetwork( }; } - const args = parsed.data; const client = createClientFromEnv(); - const result: SearchResponse = await client.search(args.query, { - limit: args.limit, - offset: args.offset, - sort: args.sort, - source: args.source, - paths: false, - }); - - // Filter by source if specified - let hits = result.hits; - if (args.source) { - hits = hits.filter((h) => h.source === args.source); - } - - // Per-row paths in parallel (the search route returns no embedded paths; - // we mirror the web UI's lazy /api/search/path lookup). - const PATH_FETCH_LIMIT = 5; - const paths: ConnectionPath[] = args.include_paths - ? ( - await Promise.all( - hits.slice(0, PATH_FETCH_LIMIT).map((h) => - client - .path({ to: h.github_user_id, li: h.connection_linkedin_username }) - .catch(() => null), - ), - ) - ).filter((p): p is ConnectionPath => p != null) - : []; - - // Format as readable text for the agent - const lines: string[] = []; - lines.push(`Found ${hits.length} result${hits.length !== 1 ? "s" : ""} for "${args.query}"`); - if (result.hasMore) lines.push("(more results available with pagination)"); - lines.push(""); - - for (const hit of hits) { - const name = [hit.connection_first_name, hit.connection_last_name] - .filter(Boolean) - .join(" "); - const login = hit.github_login ? `@${hit.github_login}` : ""; - const display = name ? `${name}${login ? ` (${login})` : ""}` : login || "Unknown"; - - lines.push(`• ${display}`); - if (hit.connection_company) lines.push(` Company: ${hit.connection_company}`); - if (hit.profile_headline) lines.push(` Headline: ${hit.profile_headline}`); - lines.push(` Source: ${hit.source} | Matched on: ${hit.matched_on}`); - const skills = [...hit.profile_skills, ...hit.topics].slice(0, 5); - if (skills.length > 0) lines.push(` Skills: ${skills.join(", ")}`); - lines.push(""); - } - - if (paths.length > 0) { - lines.push("Connection Paths:"); - for (const path of paths) { - lines.push(formatPathText(path)); - } - } - + const body = await client.capabilitySearch(parsed.data); return { jsonrpc: "2.0", id, result: { - content: [ - { type: "text", text: lines.join("\n") }, - { type: "text", text: JSON.stringify({ hits, paths, hasMore: result.hasMore }) }, - ], + content: [{ type: "text", text: JSON.stringify(body) }], }, }; } -async function handleGetConnectionPath( +async function handleExecute( id: string | number | null, rawArgs: Record, ): Promise { - // Validate input against Zod schema - const parsed = GetConnectionPathArgsSchema.safeParse(rawArgs); + const parsed = ExecuteArgsSchema.safeParse(rawArgs); if (!parsed.success) { - const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); + const issues = parsed.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; "); return { jsonrpc: "2.0", id, @@ -452,67 +364,16 @@ async function handleGetConnectionPath( }; } - const args = parsed.data; const client = createClientFromEnv(); - - // Resolve {to, li} from the supplied target. If only `query` was given, - // search and use the best match. - let to: number | null = args.github_user_id ?? null; - let li: string | null = args.linkedin_username ?? null; - let label = args.query ?? (to ? `#${to}` : (li ? `@${li}` : "target")); - - if (!to && !li) { - const search = await client.search(args.query!, { limit: 5, paths: false }); - const candidates = search.hits.filter( - (h) => h.github_user_id || h.connection_linkedin_username, - ); - if (candidates.length === 0) { - return { - jsonrpc: "2.0", - id, - result: { - content: [ - { type: "text", text: `No matching profiles found for "${args.query}".` }, - ], - }, - }; - } - const best = candidates[0]!; - to = best.github_user_id; - li = best.connection_linkedin_username; - const bestName = - [best.connection_first_name, best.connection_last_name].filter(Boolean).join(" ") || - best.github_login || - best.connection_linkedin_username || - label; - label = bestName; - } - - const path = await client.path({ to, li }); - - if (!path) { - return { - jsonrpc: "2.0", - id, - result: { - content: [ - { - type: "text", - text: `No connection path found to ${label}. They may be outside your reachable network.`, - }, - ], - }, - }; - } - + const body = await client.capabilityExecute({ + capability: parsed.data.capability, + args: parsed.data.args ?? {}, + }); return { jsonrpc: "2.0", id, result: { - content: [ - { type: "text", text: `Connection path to ${label}:\n${formatPathText(path)}` }, - { type: "text", text: JSON.stringify({ path }) }, - ], + content: [{ type: "text", text: JSON.stringify(body) }], }, }; } @@ -521,24 +382,6 @@ async function handleGetConnectionPath( // Utilities // --------------------------------------------------------------------------- -function formatPathText(path: ConnectionPath): string { - const profileMap = new Map( - path.profiles.map((p) => [p.github_user_id, p]), - ); - - const startProfile = profileMap.get(path.from_user_id); - const parts: string[] = [startProfile?.name ?? startProfile?.login ?? "You"]; - - for (const hop of path.hops) { - const profile = profileMap.get(hop.to_user_id); - const name = profile?.name ?? profile?.login ?? `#${hop.to_user_id}`; - const edge = hop.edge_type === "linkedin" ? "──LinkedIn──▸" : "──collab──▸"; - parts.push(`${edge} ${name}`); - } - - return ` ${parts.join(" ")} (${path.total_hops} hop${path.total_hops !== 1 ? "s" : ""})`; -} - function sendResponse(response: JsonRpcResponse): void { const json = JSON.stringify(response); process.stdout.write(json + "\n");