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
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,26 @@ Long-running entities can get orphaned if the bundle subprocess dies mid-run. Th

`sanitizeLineField()` and XML containment tags in `compose.ts` are prompt injection mitigations. Do not remove without reviewing `test/unit/prompt-injection.test.ts`. The `DELEGATE_PREAMBLE` in `delegate.ts` prevents task-as-system-prompt injection.

## API Surfaces — Three Audiences

The platform serves three audiences with three protocol surfaces. They are not tiers; they are distinct contracts for distinct callers, intentionally split.

| Audience | Surface | When |
|---|---|---|
| External MCP clients (Claude Code, Claude Desktop, Cursor, any RFC-conformant client) | `POST /mcp` (Streamable HTTP MCP) | Any caller speaking the MCP protocol from outside the platform. Stateful: server allocates `Mcp-Session-Id` bound to workspace + identity. |
| Iframe widgets (synapse apps in sandboxed `<iframe>`s) | postMessage → `bridge.ts` → MCP SDK Client → `/mcp` | Sandboxed UI talking via the MCP App ext-apps protocol. The bridge is the only iframe path; it shares one `Mcp-Session-Id` per browser tab via a singleton client. |
| Platform's own web shell (first-party React UI: header, settings, chat) | `POST /v1/tools/call`, `POST /v1/resources/read`, `GET /v1/...` (REST) | Trusted same-origin code. Stateless per request: `X-Workspace-Id` header on each fetch; no session, no transport lifecycle. |

**Quick decision rules for contributors:**

- Adding a new feature to a settings tab, the chat composer, or anywhere in `web/src/` outside `web/src/bridge/` → use the REST helpers in `web/src/api/client.ts`. Do not import the MCP bridge client.
- Adding a feature to a synapse app (lives in `synapse-apps/<name>/ui/`) → use `@nimblebrain/synapse`'s `callTool` / `callToolAsTask` / `readResource`. The SDK speaks postMessage; the bridge handles the rest.
- Adding a new `nb__*` built-in tool → register it in the engine; both REST and `/mcp` audiences pick it up automatically. Don't add a special endpoint.

**Why split**, not consolidate: the web shell and external MCP clients have different correctness requirements. The shell is trusted same-origin React with its own React lifecycle; making it speak MCP would force it into stateful session lifecycle (workspace-bound `Mcp-Session-Id`, reset on switch, etc.) for zero gain. Keeping it on stateless REST means workspace switching is a no-op on transport state — next fetch reads the new `X-Workspace-Id` and goes. The bridge needs MCP because external MCP clients also use `/mcp`, so iframes inherit a spec-aligned protocol surface for free.

`/v1/tools/call` and `/v1/resources/read` are NOT being deprecated. They are the platform's first-party API and stay alive indefinitely.

## MCP App Bridge Rules

These cause production bugs if violated:
Expand All @@ -231,6 +251,7 @@ These cause production bugs if violated:
- Bridge must guard listeners with `destroyed` flag (React StrictMode double-mounts)
- `SlotRenderer` effect depends only on `placementKey` (callbacks via refs, not deps)
- Shell components must not consume `ChatContext` (use `ChatConfigContext` instead)
- `setAuthToken` and `setActiveWorkspaceId` in `web/src/api/client.ts` fire a registered lifecycle handler on real changes only (equality-guarded). The bridge MCP client registers `resetMcpBridgeClient` here at module load to drop its workspace-bound session on switch / logout. Stateless callers (REST helpers) read the current values per-request and need no hook.

## Auto-Generated Files

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@
### Highlights

- **Platform sources unified on MCP.** Every tool/resource provider — built-in platform capabilities and user-installed bundles alike — is now an MCP server. Built-ins run in-process over `InMemoryTransport`; bundles continue to run as subprocess or remote MCP. One contract, one shape, one set of capabilities.
- **Task-aware iframe tools** — widgets can now `callToolAsTask` for long-running tools (research runs, batch imports). The `/mcp` endpoint speaks the MCP 2025-11-25 tasks utility, and the iframe bridge always routes `tools/call` and `resources/read` through `/mcp` (legacy REST branches in the bridge are gone). The `/v1/tools/call` and `/v1/resources/read` REST endpoints stay for the web shell.

### Added

- `nb__read_resource` system tool — the agent can now load `skill://` / `ui://` resources advertised by an installed bundle's MCP server ([#3](https://github.com/NimbleBrainInc/nimblebrain/pull/25)).
- `defineInProcessApp` helper for building in-process MCP sources from JSON Schema tool defs and a resource map — same authoring ergonomic as the former `InlineSource`, with the full MCP capability surface (resources, instructions, tasks, future capabilities).
- `/mcp` advertises `tasks` and `resources` capabilities; tool-level `taskSupport` negotiation enforces JSON-RPC `-32601` for required-without-task and forbidden-with-task.
- `McpTaskStore` (in-memory, keyed by `${workspaceId}:${identityId}:${taskId}`) routes `tasks/{get,result,cancel}` to the originating engine handle with workspace-scoped authz; cross-tenant lookups return not-found.
- `McpSource` per-phase task methods (`startToolAsTask`, `awaitToolTaskResult`, `getTaskStatus`, `cancelTask`) with owner-context enforcement and TTL sweeper.

### Changed

- Apps list in the system prompt now surfaces each bundle's `initialize.instructions` inside `<app-instructions>` containment tags, so per-bundle guidance reaches the LLM.
- Iframe bridge uses the MCP transport (`StreamableHTTPClientTransport` against `/mcp`) for `tools/call` and `resources/read`. `INTERNAL_APPS` authz still precedes transport selection. Bridge advertises `hostCapabilities.tasks` to iframes and forwards `notifications/tasks/status` on a per-bridge subscription.
- Inline (non-task) `tools/call` handler on `/mcp` now preserves `structuredContent` (was dropping it).

### Fixed

Expand All @@ -22,6 +28,7 @@
### Removed

- `InlineSource`, `ResourceReader`, `isResourceReader`. External callers should switch to `defineInProcessApp` (returns an `McpSource`); `InlineToolDef` becomes `InProcessTool` with the same shape.
- `bridgeUseMcp` feature flag and its scaffolding (`web/src/features.ts`, `getBridgeUseMcp` / `setBridgeUseMcp`, the schema entry, the resolver field). The MCP transport is the only path; legacy REST branches in the bridge are deleted.

## [0.4.0] - 2026-04-24

Expand Down
213 changes: 212 additions & 1 deletion src/api/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,36 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import {
CallToolRequestSchema,
type CreateTaskResult,
ErrorCode,
isInitializeRequest,
ListResourcesRequestSchema,
ListToolsRequestSchema,
McpError,
ReadResourceRequestSchema,
type Resource,
type ServerCapabilities,
} from "@modelcontextprotocol/sdk/types.js";
import { isToolEnabled, isToolVisibleToRole, type ResolvedFeatures } from "../config/features.ts";
import type { UserIdentity } from "../identity/provider.ts";
import { type RequestContext, runWithRequestContext } from "../runtime/request-context.ts";
import { McpSource } from "../tools/mcp-source.ts";
import type { ToolRegistry } from "../tools/registry.ts";
import {
createMcpTaskStore,
type McpTaskStore,
type OwnerContext,
type TaskAwareSource,
} from "./mcp-task-store.ts";

/**
* JSON-RPC error code for "resource not found".
*
* MCP specifies this code for `resources/read` when the URI can't be resolved.
* It's not part of the base JSON-RPC 2.0 set nor the SDK's `ErrorCode` enum
* (which only covers JSON-RPC's reserved range), so we declare it here.
*/
const RESOURCE_NOT_FOUND_CODE = -32002;

const mcpPkgPath = resolve(import.meta.dirname ?? __dirname, "../../package.json");
const mcpPkg = JSON.parse(readFileSync(mcpPkgPath, "utf-8")) as {
Expand Down Expand Up @@ -78,6 +101,20 @@ export interface McpWorkspaceContext {
workspaceId: string | null;
}

/**
* Server capabilities for tasks utility (MCP draft 2025-11-25).
*
* - `cancel: {}` — we accept `tasks/cancel` and route through McpSource.cancelTask
* - `requests.tools.call: {}` — we accept task-augmented `tools/call` (CreateTaskResult)
* - `list` is deliberately absent — `tasks/list` is deferred (see SPEC_REFERENCE §Deferred).
*
* Shape defined by `ServerCapabilitiesSchema.tasks` in the SDK types.
*/
const TASKS_CAPABILITY: NonNullable<ServerCapabilities["tasks"]> = {
cancel: {},
requests: { tools: { call: {} } },
};

/**
* Create a new MCP Server instance wired to the given ToolRegistry.
* Each session gets its own Server + Transport pair.
Expand All @@ -94,9 +131,32 @@ function createServer(
// Workspace context is required — every request must be workspace-scoped
const activeRegistry = workspaceCtx?.registry ?? registry; // registry is always workspace-scoped now

// Build a session-scoped in-memory task store. The SDK installs handlers
// for tasks/{get,result,cancel,list} automatically when this is passed via
// ProtocolOptions.taskStore — we never register them ourselves. The store
// binds every task to (workspaceId, identityId) so cross-tenant lookups
// surface as -32602 "task not found" per spec §8 security guidance.
//
// Tasks require a workspace for authorization binding. If no workspace was
// resolved (unauthenticated dev path), the capability isn't advertised and
// the endpoint behaves as if tasks were disabled.
const taskStore: McpTaskStore | undefined = workspaceCtx?.workspaceId
? createMcpTaskStore({
identity: workspaceCtx.identity,
workspaceId: workspaceCtx.workspaceId,
})
: undefined;

const server = new Server(
{ name: "nimblebrain", version: MCP_SERVER_VERSION },
{ capabilities: { tools: {} } },
{
capabilities: {
tools: {},
resources: {},
...(taskStore ? { tasks: TASKS_CAPABILITY } : {}),
},
...(taskStore ? { taskStore } : {}),
},
);

server.setRequestHandler(ListToolsRequestSchema, async () => {
Expand All @@ -120,6 +180,9 @@ function createServer(

server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const taskParam = request.params.task; // { ttl?, pollInterval? } | undefined
const isTaskRequest = taskParam !== undefined;

if (!isToolEnabled(name, features)) {
return {
content: [{ type: "text" as const, text: `Tool "${name}" is disabled` }],
Expand All @@ -133,6 +196,42 @@ function createServer(
};
}

// ── Tool-level task negotiation (MCP spec 2025-11-25 §tasks) ─────────
//
// The low-level SDK `Server` validates the *result shape* against the
// request (CreateTaskResult vs CallToolResult) but does NOT enforce the
// tool-level taskSupport semantics. We do that here:
// - `required` + no task param → -32601 MethodNotFound
// - `forbidden`/absent + task → -32601 MethodNotFound
// - `optional` → either path is legal
//
// See `src/tools/types.ts::Tool.execution.taskSupport` for the field.
const sepIndex = name.indexOf("__");
const sourceName = sepIndex >= 0 ? name.slice(0, sepIndex) : null;
const localName = sepIndex >= 0 ? name.slice(sepIndex + 2) : name;
const taskAwareSource = sourceName ? activeRegistry.findTaskAwareSource(sourceName) : null;
// Inspect the cached tool definition (if the source is MCP-backed) to
// read `taskSupport`. Non-MCP sources never support tasks.
let taskSupport: "optional" | "required" | "forbidden" | undefined;
if (taskAwareSource) {
const tools = await taskAwareSource.tools();
const tool = tools.find((t) => t.name === name);
taskSupport = tool?.execution?.taskSupport;
}

if (taskSupport === "required" && !isTaskRequest) {
throw new McpError(
ErrorCode.MethodNotFound,
`Tool ${name} requires task augmentation (taskSupport: 'required')`,
);
}
if (isTaskRequest && (!taskSupport || taskSupport === "forbidden")) {
throw new McpError(
ErrorCode.MethodNotFound,
`Tool ${name} does not support task augmentation (taskSupport: ${taskSupport ?? "none"})`,
);
}

// Build per-request context for AsyncLocalStorage (concurrency-safe)
const reqCtx: RequestContext = {
identity: workspaceCtx?.identity ?? null,
Expand All @@ -141,6 +240,43 @@ function createServer(
workspaceModelOverride: null,
};

// ── Task-augmented path ─────────────────────────────────────────────
//
// Return a CreateTaskResult immediately. The McpSource has already
// started the stream and is draining it in the background; its
// TaskHandle holds the terminal deferred for later `tasks/result` and
// its abortController for `tasks/cancel`. We stash the (source, owner)
// pair in the session's task store so the SDK-installed task handlers
// can find their way back.
if (isTaskRequest && taskAwareSource && taskStore && workspaceCtx?.workspaceId) {
const ownerContext: OwnerContext = {
workspaceId: workspaceCtx.workspaceId,
...(workspaceCtx.identity?.id ? { identityId: workspaceCtx.identity.id } : {}),
};
const createResult: CreateTaskResult = await runWithRequestContext(reqCtx, () =>
taskAwareSource.startToolAsTask(localName, (args ?? {}) as Record<string, unknown>, {
ownerContext,
...(taskParam.ttl !== undefined ? { ttlMs: taskParam.ttl } : {}),
}),
);
taskStore.recordTask({
source: taskAwareSource as unknown as TaskAwareSource,
toolFullName: name,
task: createResult.task,
ownerContext,
});
return createResult;
}

// ── Inline path ─────────────────────────────────────────────────────
//
// Preserve `structuredContent` — dropping it was a long-standing bug
// that silently violated `CallToolResult must be returned as-is`
// (SPEC_REFERENCE §Non-Negotiable Rule 4). `_meta` propagation on the
// inline path is a no-op today because the engine's ToolResult shape
// doesn't carry `_meta`; task-augmented flows carry `_meta` through
// naturally because `tasks/result` returns the full CallToolResult
// directly from `awaitToolTaskResult` (see mcp-task-store.ts).
const result = await runWithRequestContext(reqCtx, () =>
activeRegistry.execute({
id: crypto.randomUUID(),
Expand All @@ -150,10 +286,85 @@ function createServer(
);
return {
content: result.content,
...(result.structuredContent !== undefined
? { structuredContent: result.structuredContent }
: {}),
isError: result.isError,
};
});

// ── resources/list ────────────────────────────────────────────────
//
// Aggregate resources from every source in the workspace registry.
//
// Delegates to each source's underlying MCP client (via `McpSource`) so we
// don't duplicate the server-side resource metadata. Sources that don't
// expose resources (e.g. plain `InlineSource` without async reads) are
// skipped. Sources that throw on `listResources` are skipped too — one
// broken bundle must not take down the workspace-wide listing.
//
// Pagination: MVP returns everything in a single response (no `cursor`
// plumbing). The SDK type allows `nextCursor`, but iframe consumers today
// enumerate the full list. Document here so we remember to add cursor
// support if/when resource counts grow beyond a few hundred per workspace.
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const resources: Resource[] = [];
for (const source of activeRegistry.getSources()) {
if (!(source instanceof McpSource)) continue;
const client = source.getClient();
if (!client) continue;
try {
const result = await client.listResources();
for (const r of result.resources) {
resources.push(r as Resource);
}
} catch {
// Source didn't implement resources/list, or transport hiccup.
// Swallow per-source errors so one bad source doesn't kill the list.
}
}
return { resources };
});

// ── resources/read ────────────────────────────────────────────────
//
// Iterate workspace-scoped sources and return the first `ReadResourceResult`
// that resolves. Mirrors the `nb__read_resource` tool's dispatch loop, but
// returns the full MCP `contents[]` shape (including `blob` for binaries)
// rather than flattening to text.
//
// Cross-workspace lookups: because we only iterate `activeRegistry`
// (already scoped via `ensureWorkspaceRegistry`), a URI owned by a source
// that lives in a different workspace simply isn't found and returns
// `-32002`. We deliberately do not distinguish "doesn't exist anywhere"
// from "exists but you can't see it" — per MCP spec guidance, avoid
// leaking cross-tenant existence information.
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;

for (const source of activeRegistry.getSources()) {
// Prefer the MCP client path — returns the raw `ReadResourceResult`
// (including binary `blob` payloads) as-is so callers get spec shape.
if (source instanceof McpSource) {
const client = source.getClient();
if (!client) continue;
try {
const result = await client.readResource({ uri });
if (result.contents && result.contents.length > 0) {
return result;
}
} catch {
// Resource not found on this source; keep trying the next one.
}
continue;
}
}

// No source resolved the URI. Per MCP spec, raise a JSON-RPC error —
// the SDK transport converts McpError into a proper `error` envelope.
throw new McpError(RESOURCE_NOT_FOUND_CODE, `Resource not found: ${uri}`, { uri });
});

return server;
}

Expand Down
Loading