diff --git a/docs/v3/guides/integrations/mcp.mdx b/docs/v3/guides/integrations/mcp.mdx index d6beeca2..5d9c29ce 100644 --- a/docs/v3/guides/integrations/mcp.mdx +++ b/docs/v3/guides/integrations/mcp.mdx @@ -239,38 +239,11 @@ To teach Goose the recommended memory flow, save the [instructions](https://raw. --- -## Optional Configuration +## Workspace -You can target a specific workspace by adding an extra header. It's optional. +Every workspace-scoped tool takes a `workspace_id` argument. You can also set `X-Honcho-Workspace-ID` on the connection; that value fills `workspace_id` when the argument is omitted. -| Header | Default | Description | -|--------|---------|-------------| -| `Authorization` | *required* | `Bearer hch-your-key-here` | -| `X-Honcho-Workspace-ID` | `"default"` | Isolate memory per project | - -Example with all headers (Claude Desktop format): - -```json -{ - "mcpServers": { - "honcho": { - "command": "npx", - "args": [ - "mcp-remote", - "https://mcp.honcho.dev", - "--header", - "Authorization:${AUTH_HEADER}", - "--header", - "X-Honcho-Workspace-ID:${WORKSPACE_ID}" - ], - "env": { - "AUTH_HEADER": "Bearer hch-your-key-here", - "WORKSPACE_ID": "my-project" - } - } - } -} -``` +Use `list_workspaces` to discover IDs (each result includes metadata and `created_at`), or `create_workspace` if none fit, then reuse the same ID for subsequent tool calls. --- diff --git a/mcp/README.md b/mcp/README.md index 7ec5963d..3bd24217 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -26,15 +26,11 @@ A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://m } ``` -### Optional Headers - -| Header | Default | Description | -| --- | --- | --- | -| `X-Honcho-Workspace-ID` | `"default"` | Workspace to operate in | +Every workspace-scoped tool takes a `workspace_id` argument. If you set `X-Honcho-Workspace-ID` on the connection, that value fills `workspace_id` when the argument is omitted. Use `list_workspaces` to discover IDs. ## Available Tools -**Workspace:** `inspect_workspace` (aggregates metadata, configuration, and peer/session IDs), `list_workspaces` (enumerates accessible workspaces), `search` (semantic search scoped by optional peer/session params), `get_metadata`, `set_metadata` +**Workspace:** `list_workspaces` (id, metadata, created_at), `create_workspace` (get-or-create with optional metadata), `inspect_workspace` (aggregates metadata, configuration, and peer/session IDs), `search` (semantic search scoped by optional peer/session params), `get_metadata`, `set_metadata` **Peers:** `create_peer`, `list_peers`, `chat`, `get_peer_card`, `set_peer_card`, `get_peer_context`, `get_representation` @@ -50,7 +46,7 @@ A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://m src/ index.ts # Worker entry point — parse config, delegate to MCP handler server.ts # createServer() — registers all tools on an McpServer - config.ts # HonchoConfig, parseConfig(), createClient() + config.ts # HonchoConfig, parseConfig(), createClientFactory() types.ts # ToolContext, result helpers tools/ workspace.ts # inspect, list, search, metadata diff --git a/mcp/instructions.md b/mcp/instructions.md index 2abc05ff..13d7dbc7 100644 --- a/mcp/instructions.md +++ b/mcp/instructions.md @@ -4,12 +4,21 @@ The simplest way to use Honcho for a standard user/assistant conversation. Three steps using the general tools. +Every workspace-scoped tool takes `workspace_id`. The simplest setup is for the client to set `X-Honcho-Workspace-ID` on the connection — then omit `workspace_id` on every call. Do not list or create a workspace just to rediscover a header that is already set. + +If the header is unset and you don't already know the workspace: + +1. Call `list_workspaces` and pick the workspace whose id or metadata best matches this work. +2. If none fit, call `create_workspace` with a descriptive id (and optional metadata like `{ "project": "...", "purpose": "..." }`). +3. Reuse that same `workspace_id` for the rest of the conversation. + ### 1. Start a conversation (once per conversation) Create a session and set up the user and assistant peers: ``` create_session + workspace_id: "" session_id: "" ``` @@ -17,12 +26,15 @@ Then add peers to the session: ``` create_peer + workspace_id: "" peer_id: "" create_peer + workspace_id: "" peer_id: "Assistant" add_peers_to_session + workspace_id: "" session_id: "" peers: - peer_id: "" @@ -39,6 +51,7 @@ Store the `session_id` for the rest of this conversation. ``` chat + workspace_id: "" peer_id: "Assistant" query: "What communication style does this user prefer?" target_peer_id: "" @@ -58,6 +71,7 @@ This calls Honcho's reasoning system to answer your question about the user, gro ``` add_messages_to_session + workspace_id: "" session_id: "" messages: - peer_id: "" @@ -88,8 +102,9 @@ The full API for advanced use cases. | Tool | When to use | | --- | --- | -| `inspect_workspace` | Inspect a single workspace's details | -| `list_workspaces` | Enumerate available workspaces | +| `list_workspaces` | Discover available workspaces (id, metadata, created_at). No `workspace_id` needed. | +| `create_workspace` | Get or create a workspace when none of the listed ones fit | +| `inspect_workspace` | Inspect a single workspace's details. Requires `workspace_id`. | | `search` | Semantic search across messages — scope with optional `peer_id` or `session_id` params | | `get_metadata` | Read metadata for workspace, peer, or session (scope with optional `peer_id` or `session_id`) | | `set_metadata` | Store metadata for workspace, peer, or session (scope with optional `peer_id` or `session_id`) | diff --git a/mcp/server.json b/mcp/server.json index 704e788b..0b747257 100644 --- a/mcp/server.json +++ b/mcp/server.json @@ -21,7 +21,7 @@ }, { "name": "X-Honcho-Workspace-ID", - "description": "Optional. Target Honcho workspace; defaults to 'default' when omitted.", + "description": "Optional. Default Honcho workspace for tool calls. When set, it fills workspace_id on tools.", "isRequired": false, "isSecret": false } diff --git a/mcp/src/config.ts b/mcp/src/config.ts index 98cf8760..34484822 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -3,7 +3,8 @@ import { Honcho } from "@honcho-ai/sdk"; export interface HonchoConfig { apiKey: string; baseUrl: string; - workspaceId: string; + /** From X-Honcho-Workspace-ID when set. */ + workspaceId?: string; } export interface Env { @@ -19,6 +20,9 @@ export interface Env { * instance (see the "Self-Hosted Honcho" section in README.md). It is * intentionally not exposed as a request header: routing public requests * to an internal URL would be a latency and security regression. + * + * Optional `X-Honcho-Workspace-ID` becomes the default `workspace_id` on + * tools. If the header is omitted, each tool call must pass `workspace_id`. */ export function parseConfig(request: Request, env: Env = {}): HonchoConfig { const authHeader = request.headers.get("Authorization"); @@ -33,17 +37,60 @@ export function parseConfig(request: Request, env: Env = {}): HonchoConfig { throw new Error("Authorization header is empty after 'Bearer '."); } + const workspaceId = + request.headers.get("X-Honcho-Workspace-ID")?.trim() || undefined; + return { apiKey, baseUrl: env.HONCHO_API_URL?.trim() || "https://api.honcho.dev", - workspaceId: request.headers.get("X-Honcho-Workspace-ID")?.trim() || "default", + workspaceId, }; } -export function createClient(config: HonchoConfig): Honcho { +export const MISSING_WORKSPACE_ID_MESSAGE = + "Missing workspace_id. Pass workspace_id on the next tool call, or set the X-Honcho-Workspace-ID header on the connection so it is used automatically."; + +export function resolveWorkspaceId( + config: HonchoConfig, + workspaceId?: string, +): string { + const id = workspaceId?.trim() || config.workspaceId?.trim(); + if (!id) { + throw new Error(MISSING_WORKSPACE_ID_MESSAGE); + } + return id; +} + +export function createClient( + config: HonchoConfig, + workspaceId: string, +): Honcho { return new Honcho({ apiKey: config.apiKey, baseURL: config.baseUrl, - workspaceId: config.workspaceId, + workspaceId, }); } + +/** Client used only for credential-scoped ops (list workspaces). */ +export function createUnscopedClient(config: HonchoConfig): Honcho { + return new Honcho({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); +} + +export function createClientFactory( + config: HonchoConfig, +): (workspaceId?: string) => Honcho { + const cache = new Map(); + return (workspaceId?: string) => { + const id = resolveWorkspaceId(config, workspaceId); + let client = cache.get(id); + if (!client) { + client = createClient(config, id); + cache.set(id, client); + } + return client; + }; +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index a7e0f107..52123dee 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -1,5 +1,10 @@ import { createMcpHandler } from "agents/mcp"; -import { parseConfig, createClient, type Env } from "./config.js"; +import { + parseConfig, + createClientFactory, + createUnscopedClient, + type Env, +} from "./config.js"; import { createServer } from "./server.js"; const CORS_ORIGIN = "*"; @@ -66,8 +71,11 @@ export default { } try { - const honcho = createClient(config); - const server = createServer({ honcho, config }); + const server = createServer({ + config, + clientFor: createClientFactory(config), + unscoped: createUnscopedClient(config), + }); const handler = createMcpHandler(server, { route: "/", corsOptions: { diff --git a/mcp/src/tools/conclusions.ts b/mcp/src/tools/conclusions.ts index f6170fce..33674275 100644 --- a/mcp/src/tools/conclusions.ts +++ b/mcp/src/tools/conclusions.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ToolContext } from "../types.js"; -import { textResult, errorResult } from "../types.js"; +import { textResult, errorResult, workspaceIdSchema } from "../types.js"; export function register(server: McpServer, ctx: ToolContext) { // ── list_conclusions ──────────────────────────────────────────────── @@ -14,6 +14,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns conclusion objects with pagination metadata.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The observer peer."), target_peer_id: z .string() @@ -23,9 +24,9 @@ export function register(server: McpServer, ctx: ToolContext) { ), }, }, - async ({ peer_id, target_peer_id }) => { + async ({ workspace_id, peer_id, target_peer_id }) => { try { - const peer = await ctx.honcho.peer(peer_id); + const peer = await ctx.clientFor(workspace_id).peer(peer_id); const scope = target_peer_id ? peer.conclusionsOf(target_peer_id) : peer.conclusions; @@ -61,6 +62,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns an array of matching conclusions ranked by relevance.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The observer peer."), query: z.string().describe("Semantic search query."), target_peer_id: z @@ -79,9 +81,9 @@ export function register(server: McpServer, ctx: ToolContext) { ), }, }, - async ({ peer_id, query, target_peer_id, top_k, filters }) => { + async ({ workspace_id, peer_id, query, target_peer_id, top_k, filters }) => { try { - const peer = await ctx.honcho.peer(peer_id); + const peer = await ctx.clientFor(workspace_id).peer(peer_id); const scope = target_peer_id ? peer.conclusionsOf(target_peer_id) : peer.conclusions; @@ -115,6 +117,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns the number of conclusions created.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The observer peer."), target_peer_id: z .string() @@ -130,9 +133,15 @@ export function register(server: McpServer, ctx: ToolContext) { ), }, }, - async ({ peer_id, target_peer_id, conclusions, session_id }) => { + async ({ + workspace_id, + peer_id, + target_peer_id, + conclusions, + session_id, + }) => { try { - const peer = await ctx.honcho.peer(peer_id); + const peer = await ctx.clientFor(workspace_id).peer(peer_id); const scope = peer.conclusionsOf(target_peer_id); const params = conclusions.map((content) => ({ content, @@ -160,6 +169,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Use this to remove incorrect or outdated knowledge.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The observer peer."), target_peer_id: z .string() @@ -167,9 +177,9 @@ export function register(server: McpServer, ctx: ToolContext) { conclusion_id: z.string().describe("The conclusion to delete."), }, }, - async ({ peer_id, target_peer_id, conclusion_id }) => { + async ({ workspace_id, peer_id, target_peer_id, conclusion_id }) => { try { - const peer = await ctx.honcho.peer(peer_id); + const peer = await ctx.clientFor(workspace_id).peer(peer_id); const scope = peer.conclusionsOf(target_peer_id); await scope.delete(conclusion_id); return textResult("Conclusion deleted successfully"); diff --git a/mcp/src/tools/peers.ts b/mcp/src/tools/peers.ts index 26457bcf..564d5737 100644 --- a/mcp/src/tools/peers.ts +++ b/mcp/src/tools/peers.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ToolContext } from "../types.js"; -import { textResult, errorResult } from "../types.js"; +import { textResult, errorResult, workspaceIdSchema } from "../types.js"; export function register(server: McpServer, ctx: ToolContext) { // ── create_peer ───────────────────────────────────────────────────── @@ -14,6 +14,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns the peer ID and any configuration that was set.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("Unique identifier for the peer."), configuration: z .object({ @@ -25,9 +26,11 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Optional peer configuration."), }, }, - async ({ peer_id, configuration }) => { + async ({ workspace_id, peer_id, configuration }) => { try { - const peer = await ctx.honcho.peer(peer_id, { configuration }); + const peer = await ctx.clientFor(workspace_id).peer(peer_id, { + configuration, + }); return textResult({ peer_id: peer.id, configuration: peer.configuration }); } catch (e) { return errorResult( @@ -42,15 +45,17 @@ export function register(server: McpServer, ctx: ToolContext) { "list_peers", { description: [ - "List peers in the current workspace (paginated).", + "List peers in the given workspace (paginated).", "Use this to discover which users and agents exist.", "Returns peer IDs with pagination metadata.", ].join("\n"), - inputSchema: {}, + inputSchema: { + workspace_id: workspaceIdSchema(ctx), + }, }, - async () => { + async ({ workspace_id }) => { try { - const page = await ctx.honcho.peers(); + const page = await ctx.clientFor(workspace_id).peers(); return textResult({ peers: page.items.map((p) => ({ id: p.id })), total: page.total, @@ -75,6 +80,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns a natural-language answer, or 'None' if no relevant information exists.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The peer to query about."), query: z.string().describe("Natural-language question."), target_peer_id: z @@ -93,9 +99,16 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Reasoning effort. Higher = more detailed but slower."), }, }, - async ({ peer_id, query, target_peer_id, session_id, reasoning_level }) => { + async ({ + workspace_id, + peer_id, + query, + target_peer_id, + session_id, + reasoning_level, + }) => { try { - const peer = await ctx.honcho.peer(peer_id); + const peer = await ctx.clientFor(workspace_id).peer(peer_id); const result = await peer.chat(query, { target: target_peer_id, session: session_id, @@ -120,6 +133,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns an array of fact strings, or null if no card exists yet.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The observer peer."), target_peer_id: z .string() @@ -129,9 +143,9 @@ export function register(server: McpServer, ctx: ToolContext) { ), }, }, - async ({ peer_id, target_peer_id }) => { + async ({ workspace_id, peer_id, target_peer_id }) => { try { - const peer = await ctx.honcho.peer(peer_id); + const peer = await ctx.clientFor(workspace_id).peer(peer_id); const card = await peer.getCard(target_peer_id); return textResult(card ?? "No peer card found."); } catch (e) { @@ -152,6 +166,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns the updated peer card.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The observer peer."), peer_card: z .array(z.string()) @@ -164,9 +179,9 @@ export function register(server: McpServer, ctx: ToolContext) { ), }, }, - async ({ peer_id, peer_card, target_peer_id }) => { + async ({ workspace_id, peer_id, peer_card, target_peer_id }) => { try { - const peer = await ctx.honcho.peer(peer_id); + const peer = await ctx.clientFor(workspace_id).peer(peer_id); const result = await peer.setCard(peer_card, target_peer_id); return textResult(result ?? "Peer card set successfully"); } catch (e) { @@ -187,6 +202,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns an object with representation, peer_card, peer_id, and target_id.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The observer peer."), target_peer_id: z .string() @@ -202,9 +218,15 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Optional: max number of conclusions to include."), }, }, - async ({ peer_id, target_peer_id, search_query, max_conclusions }) => { + async ({ + workspace_id, + peer_id, + target_peer_id, + search_query, + max_conclusions, + }) => { try { - const peer = await ctx.honcho.peer(peer_id); + const peer = await ctx.clientFor(workspace_id).peer(peer_id); const context = await peer.context({ target: target_peer_id, searchQuery: search_query, @@ -234,6 +256,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns a formatted string of conclusions.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The observer peer."), target_peer_id: z .string() @@ -254,6 +277,7 @@ export function register(server: McpServer, ctx: ToolContext) { }, }, async ({ + workspace_id, peer_id, target_peer_id, session_id, @@ -261,7 +285,7 @@ export function register(server: McpServer, ctx: ToolContext) { max_conclusions, }) => { try { - const peer = await ctx.honcho.peer(peer_id); + const peer = await ctx.clientFor(workspace_id).peer(peer_id); const rep = await peer.representation({ target: target_peer_id, session: session_id, diff --git a/mcp/src/tools/sessions.ts b/mcp/src/tools/sessions.ts index 1270ea86..2e2545be 100644 --- a/mcp/src/tools/sessions.ts +++ b/mcp/src/tools/sessions.ts @@ -7,6 +7,7 @@ import { formatMessage, formatMessages, formatSessionSummaries, + workspaceIdSchema, } from "../types.js"; export function register(server: McpServer, ctx: ToolContext) { @@ -20,12 +21,13 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns the session ID.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("Unique identifier for the session."), }, }, - async ({ session_id }) => { + async ({ workspace_id, session_id }) => { try { - const session = await ctx.honcho.session(session_id); + const session = await ctx.clientFor(workspace_id).session(session_id); return textResult({ session_id: session.id }); } catch (e) { return errorResult( @@ -40,15 +42,17 @@ export function register(server: McpServer, ctx: ToolContext) { "list_sessions", { description: [ - "List sessions in the current workspace (paginated).", + "List sessions in the given workspace (paginated).", "Use this to discover existing conversations.", "Returns session IDs with pagination metadata.", ].join("\n"), - inputSchema: {}, + inputSchema: { + workspace_id: workspaceIdSchema(ctx), + }, }, - async () => { + async ({ workspace_id }) => { try { - const page = await ctx.honcho.sessions(); + const page = await ctx.clientFor(workspace_id).sessions(); return textResult({ sessions: page.items.map((s) => ({ id: s.id })), total: page.total, @@ -72,12 +76,13 @@ export function register(server: McpServer, ctx: ToolContext) { "This cannot be undone.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session to delete."), }, }, - async ({ session_id }) => { + async ({ workspace_id, session_id }) => { try { - const session = await ctx.honcho.session(session_id); + const session = await ctx.clientFor(workspace_id).session(session_id); await session.delete(); return textResult("Session deleted successfully"); } catch (e) { @@ -98,6 +103,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns the new cloned session ID.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session to clone."), message_id: z .string() @@ -107,9 +113,9 @@ export function register(server: McpServer, ctx: ToolContext) { ), }, }, - async ({ session_id, message_id }) => { + async ({ workspace_id, session_id, message_id }) => { try { - const session = await ctx.honcho.session(session_id); + const session = await ctx.clientFor(workspace_id).session(session_id); const cloned = await session.clone(message_id); return textResult({ session_id: cloned.id }); } catch (e) { @@ -129,6 +135,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Use this to bring participants into a conversation.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session to add peers to."), peers: z .array( @@ -152,9 +159,10 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Peers to add — plain IDs or objects with per-session config."), }, }, - async ({ session_id, peers }) => { + async ({ workspace_id, session_id, peers }) => { try { - const session = await ctx.honcho.session(session_id); + const honcho = ctx.clientFor(workspace_id); + const session = await honcho.session(session_id); const additions = peers.map((p) => { if (typeof p === "string") return p; const config: { observeMe?: boolean | null; observeOthers?: boolean | null } = {}; @@ -182,15 +190,16 @@ export function register(server: McpServer, ctx: ToolContext) { "Remove one or more peers from a session.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session to remove peers from."), peer_ids: z .array(z.string()) .describe("Peer IDs to remove."), }, }, - async ({ session_id, peer_ids }) => { + async ({ workspace_id, session_id, peer_ids }) => { try { - const session = await ctx.honcho.session(session_id); + const session = await ctx.clientFor(workspace_id).session(session_id); await session.removePeers(peer_ids); return textResult("Peers removed from session successfully"); } catch (e) { @@ -211,12 +220,13 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns an array of peer IDs.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session to query."), }, }, - async ({ session_id }) => { + async ({ workspace_id, session_id }) => { try { - const session = await ctx.honcho.session(session_id); + const session = await ctx.clientFor(workspace_id).session(session_id); const peers = await session.peers(); return textResult(peers.map((p) => p.id)); } catch (e) { @@ -237,12 +247,13 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns a single JSON object.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session to inspect."), }, }, - async ({ session_id }) => { + async ({ workspace_id, session_id }) => { try { - const session = await ctx.honcho.session(session_id); + const session = await ctx.clientFor(workspace_id).session(session_id); const [peers, messagePage, summaries] = await Promise.all([ session.peers(), session.messages(), @@ -273,6 +284,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Each message must specify the peer_id of the author.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session to add messages to."), messages: z .array( @@ -288,15 +300,16 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Messages to add."), }, }, - async ({ session_id, messages }) => { + async ({ workspace_id, session_id, messages }) => { try { - const session = await ctx.honcho.session(session_id); - const peerCache = new Map>>(); + const honcho = ctx.clientFor(workspace_id); + const session = await honcho.session(session_id); + const peerCache = new Map>>(); const sessionMessages = []; for (const msg of messages) { let peer = peerCache.get(msg.peer_id); if (!peer) { - peer = await ctx.honcho.peer(msg.peer_id); + peer = await honcho.peer(msg.peer_id); peerCache.set(msg.peer_id, peer); } sessionMessages.push( @@ -325,6 +338,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns the first page of messages with pagination metadata.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session to get messages from."), filters: z .record(z.string(), z.unknown()) @@ -332,9 +346,9 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Optional metadata filter criteria."), }, }, - async ({ session_id, filters }) => { + async ({ workspace_id, session_id, filters }) => { try { - const session = await ctx.honcho.session(session_id); + const session = await ctx.clientFor(workspace_id).session(session_id); const page = await session.messages(filters); return textResult({ messages: formatMessages(page.items), @@ -360,13 +374,14 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns the message object.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session the message belongs to."), message_id: z.string().describe("The message ID to fetch."), }, }, - async ({ session_id, message_id }) => { + async ({ workspace_id, session_id, message_id }) => { try { - const session = await ctx.honcho.session(session_id); + const session = await ctx.clientFor(workspace_id).session(session_id); const message = await session.getMessage(message_id); return textResult(formatMessage(message)); } catch (e) { @@ -388,6 +403,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns messages, summary, and session ID.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), session_id: z.string().describe("The session to get context for."), summary: z .boolean() @@ -399,9 +415,9 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Target token budget for the context window."), }, }, - async ({ session_id, summary, tokens }) => { + async ({ workspace_id, session_id, summary, tokens }) => { try { - const session = await ctx.honcho.session(session_id); + const session = await ctx.clientFor(workspace_id).session(session_id); const context = await session.context({ summary, tokens }); return textResult({ session_id: context.sessionId, diff --git a/mcp/src/tools/system.ts b/mcp/src/tools/system.ts index 4c2ba673..9200d733 100644 --- a/mcp/src/tools/system.ts +++ b/mcp/src/tools/system.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ToolContext } from "../types.js"; -import { textResult, errorResult } from "../types.js"; +import { textResult, errorResult, workspaceIdSchema } from "../types.js"; export function register(server: McpServer, ctx: ToolContext) { // ── schedule_dream ────────────────────────────────────────────────── @@ -14,6 +14,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Use this after a long conversation to improve Honcho's memory quality.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z.string().describe("The observer peer to dream for."), target_peer_id: z .string() @@ -27,9 +28,9 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Optional: scope the dream to a session."), }, }, - async ({ peer_id, target_peer_id, session_id }) => { + async ({ workspace_id, peer_id, target_peer_id, session_id }) => { try { - await ctx.honcho.scheduleDream({ + await ctx.clientFor(workspace_id).scheduleDream({ observer: peer_id, observed: target_peer_id, session: session_id, @@ -52,11 +53,13 @@ export function register(server: McpServer, ctx: ToolContext) { "Use this to check if Honcho is still processing messages before querying for insights.", "Returns work unit counts: total, completed, in-progress, and pending.", ].join("\n"), - inputSchema: {}, + inputSchema: { + workspace_id: workspaceIdSchema(ctx), + }, }, - async () => { + async ({ workspace_id }) => { try { - const status = await ctx.honcho.queueStatus(); + const status = await ctx.clientFor(workspace_id).queueStatus(); return textResult(status); } catch (e) { return errorResult( diff --git a/mcp/src/tools/workspace.ts b/mcp/src/tools/workspace.ts index b3a871b3..6eaf95a6 100644 --- a/mcp/src/tools/workspace.ts +++ b/mcp/src/tools/workspace.ts @@ -1,8 +1,36 @@ import { z } from "zod"; -import { BadRequestError, UnprocessableEntityError } from "@honcho-ai/sdk"; +import { + BadRequestError, + HonchoError, + UnprocessableEntityError, + type PageResponse, + type WorkspaceResponse, +} from "@honcho-ai/sdk"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ToolContext } from "../types.js"; -import { textResult, errorResult, formatMessages } from "../types.js"; +import { resolveWorkspaceId } from "../config.js"; +import { + textResult, + errorResult, + formatMessages, + workspaceIdSchema, +} from "../types.js"; + +function withAdminKeyHint(prefix: string, e: unknown): string { + const message = e instanceof Error ? e.message : String(e); + const denied = + e instanceof HonchoError && (e.status === 401 || e.status === 403); + if (!denied) return `${prefix}: ${message}`; + return `${prefix}: ${message}. This operation is only possible with an admin API key.`; +} + +function formatWorkspace(workspace: WorkspaceResponse) { + return { + id: workspace.id, + metadata: workspace.metadata ?? {}, + created_at: workspace.created_at, + }; +} export function register(server: McpServer, ctx: ToolContext) { // ── inspect_workspace ─────────────────────────────────────────────── @@ -10,23 +38,27 @@ export function register(server: McpServer, ctx: ToolContext) { "inspect_workspace", { description: [ - "Inspect the current workspace at a glance.", + "Inspect a workspace at a glance.", "Aggregates workspace metadata, configuration, peer IDs, and session IDs.", "Returns the first page of peers/sessions with total counts.", ].join("\n"), - inputSchema: {}, + inputSchema: { + workspace_id: workspaceIdSchema(ctx), + }, }, - async () => { + async ({ workspace_id }) => { try { - const [metadata, configuration, peerPage, sessionPage] = await Promise.all([ - ctx.honcho.getMetadata(), - ctx.honcho.getConfiguration(), - ctx.honcho.peers(), - ctx.honcho.sessions(), - ]); + const honcho = ctx.clientFor(workspace_id); + const [metadata, configuration, peerPage, sessionPage] = + await Promise.all([ + honcho.getMetadata(), + honcho.getConfiguration(), + honcho.peers(), + honcho.sessions(), + ]); return textResult({ - workspace_id: ctx.honcho.workspaceId, + workspace_id: honcho.workspaceId, metadata, configuration, peer_count: peerPage.total, @@ -48,24 +80,82 @@ export function register(server: McpServer, ctx: ToolContext) { { description: [ "List workspaces accessible to the current credentials (paginated).", - "Use this to discover available workspaces before selecting or switching context.", - "Returns workspace IDs with pagination metadata.", + "Skip this if the connection already set X-Honcho-Workspace-ID — that header is the workspace; omit workspace_id on other tools.", + "Use this only when the header is unset and you don't already know the workspace ID.", + "Returns each workspace's id, metadata, and created_at. If none fit, call create_workspace.", ].join("\n"), - inputSchema: {}, + inputSchema: { + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (1-indexed)."), + size: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Results per page (max 100)."), + }, }, - async () => { + async ({ page, size }) => { try { - const page = await ctx.honcho.workspaces(); + const result = await ctx.unscoped.http.post< + PageResponse + >("/v3/workspaces/list", { + body: {}, + query: { page, size }, + }); return textResult({ - workspaces: page.items.map((id) => ({ id })), - total: page.total, - page: page.page, - pages: page.pages, + workspaces: result.items.map(formatWorkspace), + total: result.total, + page: result.page, + pages: result.pages, }); } catch (e) { - return errorResult( - `Failed to list workspaces: ${e instanceof Error ? e.message : String(e)}`, + return errorResult(withAdminKeyHint("Failed to list workspaces", e)); + } + }, + ); + + // ── create_workspace ──────────────────────────────────────────────── + server.registerTool( + "create_workspace", + { + description: [ + "Get or create a workspace with the given ID.", + "Skip this if the connection already set X-Honcho-Workspace-ID — that header pins the workspace without a create call.", + "Use this only when the header is unset and list_workspaces has no suitable workspace.", + "Optional metadata helps future list_workspaces calls identify what the workspace is for.", + "Returns the workspace id, metadata, and created_at.", + ].join("\n"), + inputSchema: { + workspace_id: workspaceIdSchema(ctx), + metadata: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Optional key-value metadata to store on the workspace (e.g. project, purpose).", + ), + }, + }, + async ({ workspace_id, metadata }) => { + try { + const id = resolveWorkspaceId(ctx.config, workspace_id); + const workspace = await ctx.unscoped.http.post( + "/v3/workspaces", + { + body: { + id, + metadata, + }, + }, ); + return textResult(formatWorkspace(workspace)); + } catch (e) { + return errorResult(withAdminKeyHint("Failed to create workspace", e)); } }, ); @@ -84,6 +174,7 @@ export function register(server: McpServer, ctx: ToolContext) { "Returns {messages, conclusions}.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), query: z.string().describe("Search query."), peer_id: z .string() @@ -116,6 +207,7 @@ export function register(server: McpServer, ctx: ToolContext) { }, }, async ({ + workspace_id, query, peer_id, session_id, @@ -125,7 +217,8 @@ export function register(server: McpServer, ctx: ToolContext) { conclusion_filters, }) => { try { - const peer = peer_id ? await ctx.honcho.peer(peer_id) : null; + const honcho = ctx.clientFor(workspace_id); + const peer = peer_id ? await honcho.peer(peer_id) : null; const messageOptions = { filters: message_filters, limit: message_limit, @@ -133,13 +226,13 @@ export function register(server: McpServer, ctx: ToolContext) { const searchMessages = async () => { if (session_id) { - const session = await ctx.honcho.session(session_id); + const session = await honcho.session(session_id); return session.search(query, messageOptions); } if (peer) { return peer.search(query, messageOptions); } - return ctx.honcho.search(query, messageOptions); + return honcho.search(query, messageOptions); }; // Conclusion search needs an (observer, observed) pair, so it only @@ -199,6 +292,7 @@ export function register(server: McpServer, ctx: ToolContext) { "- session_id only: get session metadata.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), peer_id: z .string() .optional() @@ -209,17 +303,18 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Optional: get metadata for this session."), }, }, - async ({ peer_id, session_id }) => { + async ({ workspace_id, peer_id, session_id }) => { try { + const honcho = ctx.clientFor(workspace_id); let metadata; if (session_id) { - const session = await ctx.honcho.session(session_id); + const session = await honcho.session(session_id); metadata = await session.getMetadata(); } else if (peer_id) { - const peer = await ctx.honcho.peer(peer_id); + const peer = await honcho.peer(peer_id); metadata = await peer.getMetadata(); } else { - metadata = await ctx.honcho.getMetadata(); + metadata = await honcho.getMetadata(); } return textResult(metadata); } catch (e) { @@ -242,6 +337,7 @@ export function register(server: McpServer, ctx: ToolContext) { "- session_id only: set session metadata.", ].join("\n"), inputSchema: { + workspace_id: workspaceIdSchema(ctx), metadata: z .record(z.string(), z.unknown()) .describe("Key-value pairs to set as metadata."), @@ -255,18 +351,19 @@ export function register(server: McpServer, ctx: ToolContext) { .describe("Optional: set metadata for this session."), }, }, - async ({ metadata, peer_id, session_id }) => { + async ({ workspace_id, metadata, peer_id, session_id }) => { try { + const honcho = ctx.clientFor(workspace_id); if (session_id) { - const session = await ctx.honcho.session(session_id); + const session = await honcho.session(session_id); await session.setMetadata(metadata); return textResult("Session metadata set successfully"); } else if (peer_id) { - const peer = await ctx.honcho.peer(peer_id); + const peer = await honcho.peer(peer_id); await peer.setMetadata(metadata); return textResult("Peer metadata set successfully"); } else { - await ctx.honcho.setMetadata(metadata); + await honcho.setMetadata(metadata); return textResult("Workspace metadata set successfully"); } } catch (e) { diff --git a/mcp/src/types.ts b/mcp/src/types.ts index 413b75b1..15a8d870 100644 --- a/mcp/src/types.ts +++ b/mcp/src/types.ts @@ -1,10 +1,27 @@ +import { z } from "zod"; import type { Honcho, Message, Summary, SessionSummaries } from "@honcho-ai/sdk"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { HonchoConfig } from "./config.js"; export interface ToolContext { - honcho: Honcho; config: HonchoConfig; + /** Return a Honcho client scoped to the given workspace (or the header default). */ + clientFor: (workspaceId?: string) => Honcho; + /** Client used only for credential-scoped ops (list workspaces). */ + unscoped: Honcho; +} + +/** + * Always optional at the schema layer so a missing value reaches clientFor, + * which returns a clear error (header or workspace_id on the next call). + */ +export function workspaceIdSchema(ctx: ToolContext) { + const fromHeader = ctx.config.workspaceId; + const description = fromHeader + ? `Workspace to operate in. The connection already set X-Honcho-Workspace-ID=${fromHeader}; omit this argument unless you need a different workspace.` + : "Workspace to operate in. Prefer the client setting X-Honcho-Workspace-ID on the connection — then you can omit this on every call. Only pass it (or use list_workspaces / create_workspace) when the header is unset."; + const field = z.string().optional().describe(description); + return fromHeader ? field.default(fromHeader) : field; } export function textResult( diff --git a/skills/honcho-memory/SKILL.md b/skills/honcho-memory/SKILL.md index 4c19decc..ef48dd93 100644 --- a/skills/honcho-memory/SKILL.md +++ b/skills/honcho-memory/SKILL.md @@ -60,7 +60,7 @@ If you're unsure, list your available tools and look for Honcho memory tools (an You need a Honcho API key — get one free at (starts with `hch-`). Then connect via the path you picked above — a purpose-built integration (recommended), or a raw connection: -- **MCP** — point your client at `https://mcp.honcho.dev` with two headers: `Authorization: Bearer hch-your-key-here` and `X-Honcho-User-Name: YourName` (what Honcho should call the user). Optional: `X-Honcho-Assistant-Name` (default `Assistant`) and `X-Honcho-Workspace-ID` (default `default`; set it to isolate memory per project). Restart the client fully after adding config. Per-client config snippets (Claude Desktop, Cursor, Codex, Windsurf, VS Code, Cline, Zed) are in the [MCP integration guide](https://honcho.dev/docs/v3/guides/integrations/mcp.md). Once connected, the server tells your assistant how to use the tools automatically. +- **MCP** — point your client at `https://mcp.honcho.dev` with `Authorization: Bearer hch-your-key-here`. Optional `X-Honcho-Workspace-ID` fills the `workspace_id` tool argument when omitted; otherwise pass `workspace_id` on each call (use `list_workspaces` to discover IDs). Restart the client fully after adding config. Per-client config snippets (Claude Desktop, Cursor, Codex, Windsurf, VS Code, Cline, Zed) are in the [MCP integration guide](https://honcho.dev/docs/v3/guides/integrations/mcp.md). Once connected, the server tells your assistant how to use the tools automatically. - **CLI** — use the `honcho-cli` skill. ---