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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
71 changes: 71 additions & 0 deletions __tests__/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).Authorization).toBe(
"Bearer nk_live_test",
);
expect((i.headers as Record<string, string>)["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/);
});
});
103 changes: 45 additions & 58 deletions __tests__/mcp-server.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -11,7 +11,7 @@
{
"registryType": "npm",
"identifier": "@noticed/cli",
"version": "0.2.0",
"version": "0.3.0",
"transport": { "type": "stdio" },
"runtimeHint": "npx",
"runtimeArguments": [
Expand Down
47 changes: 47 additions & 0 deletions src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
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<string, unknown>;
}): Promise<unknown> {
return this.postJson("/api/agent/capabilities/execute", args);
}

private async postJson(path: string, body: unknown): Promise<unknown> {
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<SearchHit[]> {
const url = new URL(`${this.baseUrl}/api/search/hydrate`);
const res = await fetch(url.toString(), {
Expand Down
Loading
Loading