Skip to content
Draft
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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,31 @@ npm install -g @anyproto/anytype-mcp
| `MCP_HOST` | `127.0.0.1` | Host to bind when `MCP_TRANSPORT=http`. |
| `MCP_PORT` | `3666` | Port to listen on when `MCP_TRANSPORT=http`. Must be in range 1024–65535. |
| `MCP_PASSTHROUGH_HEADERS` | `authorization,anytype-version` | Comma-separated list of inbound HTTP header names (lowercase) forwarded from the MCP HTTP client to the Anytype API. Extend with caution — arbitrary headers must not be forwarded. |
| `MCP_INSTRUCTIONS` | bundled `instructions.md` | Instructions broadcast to MCP clients on connect. `false` disables; a string overrides with custom content; `{file:/path}` loads content from a file. |
| `MCP_INSTRUCTIONS` | bundled `instructions.md` | Instructions broadcast to MCP clients on connect. `false` disables; a string overrides with custom content; `{file:/path}` loads content from a file; `anytype://object?objectId=<id>&spaceId=<id>` loads content from an Anytype page. |
| `DISCOVERY_TOOL_CONFIG` | — | JSON config for the `discover-spaces` tool. Accepts inline JSON or `{file:/path/to/config.json}`. Options: `ttlMs` (cache TTL ms, default 300000), `spaces` (per-space/type filter). |



### Custom MCP Instructions

By default the server broadcasts the bundled `instructions.md` to MCP clients on every connection.
You can replace it with your own content via `MCP_INSTRUCTIONS`:

- **Disable:** `MCP_INSTRUCTIONS=false`
- **Inline string:** `MCP_INSTRUCTIONS="Your custom instructions here"`
- **File:** `MCP_INSTRUCTIONS="{file:/path/to/instructions.md}"`
- **Anytype page:** `MCP_INSTRUCTIONS="anytype://object?objectId=<id>&spaceId=<id>"`

The Anytype page option fetches the object's markdown content at startup and uses it as the instructions string.
This lets you maintain your MCP instructions as a regular Anytype page — edit it in the app, restart the server to pick up changes.

To get a page's deep link in Anytype: open the page → three-dot menu → **Copy link**. The link has the form:
```
anytype://object?objectId=bafyrei...&spaceId=bafyrei....31e0h...
```

If the page cannot be fetched (Anytype not running, invalid link, network error), the server logs a warning and falls back to the bundled instructions. The warning is also prepended to the instructions text so the connected AI client is aware.

### discover-spaces Tool

The `discover-spaces` tool returns a complete snapshot of your Anytype workspace — all spaces with their types, properties, tags, and select option IDs — in a single call. AI assistants use it to resolve IDs before creating or updating objects, eliminating the need to chain multiple list calls.
Expand Down
73 changes: 35 additions & 38 deletions instructions.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,39 @@
# Anytype MCP Server — Instructions

You are connected to an Anytype knowledge base via the **Anytype MCP server**.
Follow these rules precisely to avoid data loss or API errors.
You are connected to an Anytype knowledge base. Follow these rules precisely.

## Glossary

- **Space** (aka Channel): container within the Vault holding a graph of objects.
- **Object**: any entity in a Space. Has a *Name* (display) and *Key* (`lower_snake_case` identifier).
- **Object Type**: describes properties and layout. System types: Page, Note, Task, Bookmark, etc. Can be user-defined.
- **Property**: attribute of an Object Type. Intrinsic (read-only) keys: `id`, `links`, `backlinks`.
- **Tag**: classification label applied to page-like objects. Space-scoped — same name ≠ same ID across spaces.
- **Deeplink**: `anytype://object?objectId=<id>&spaceId=<id>`

## Session start — mandatory first step

Call `discover-spaces` with no arguments **before any other tool call** in a new session.
It returns every space, type, property, tag, and select-option ID you need.
Cache this result mentally for the session — do not call it again unless you have performed a schema-mutating operation (creating or modifying a type, property, tag, or space).
Call `discover-spaces` (no args) **before any other tool call**.
It returns *Discover Info* — a JSON snapshot of all Spaces, Types, Properties, Tags, and their IDs, keyed by Name.
Cache mentally for the session. With *Discover Info* and a Deeplink you can get or modify any object directly.

## ID resolution
### Force-refresh when:
- After creating/modifying a **type, property, tag, or space**.
- After a 404 on an ID-dependent call.

- **Never invent or guess IDs.** Every object, space, type, property, tag, and select option has an opaque content-addressed ID returned by `discover-spaces`.
- **Tags are space-scoped.** The same tag name in two spaces has two different IDs. Always look up the ID from the correct space entry in the `discover-spaces` result.
- **Select option IDs** are nested under `spaces["<Space>"].types["<Type>"].properties["<Property>"].select["<Option>"]`.
- **Multi-Select option IDs** are nested under `spaces["<Space>"].types["<Type>"].properties["<Property>"].multi_select["<Option>"]`.
- **Property keys** (used in mutation payloads) are at `.key` on each property entry, e.g. `"stage"`, `"company_name"`.
Wipe the old result; cache the new one.

Use bracket-notation `path` to narrow the result and avoid re-fetching:
## ID rules

```
discover-spaces(path='spaces["Career"].tags')
discover-spaces(path='spaces["Career"].types["JobApplication"].properties["Stage"].select')
```
- **Never invent IDs.** Every ID comes from *Discover Info*.
- **Tags are space-scoped.** Always look up from the correct space entry.
- **Property keys** (used in payloads) are at `.key` on each property, e.g. `"stage"`, `"company_name"`.

## Creating and updating objects

Property payload shape: `{ key: <property key>, <format>: <value> }`
Payload shape: `{ key: <property key>, <format>: <value> }`

Examples by format:
Resolve `key` and `format` from `spaces["<Space>"].types["<Type>"].properties["<Property>"]` in *Discover Info*.

| Format | Payload |
| -------------- | -------------------------------------------------- |
Expand All @@ -39,37 +44,29 @@ Examples by format:
| `select` | `{ key: "stage", select: "<option-id>" }` |
| `multi_select` | `{ key: "tag", multi_select: ["<id1>", "<id2>"] }` |

**Tag property** (`key: "tag"`) is valid on every object type. Use `multi_select` with tag IDs from the space's `tags` map.
Select/multi-select option IDs: `spaces["<Space>"].types["<Type>"].properties["<Property>"].select["<Option>"]`

## Space routing

When the user's intent determines the space, apply the default routing based on a space description.

If the target space is ambiguous, ask before creating.
Route based on space descriptions from *Discover Info*. Ask if ambiguous.

## Mutation tagging — required on every write

After every `create` or `update` operation, add the space's `mcp-modified` tag to the mutated object.
Look up the ID from `spaces["<Space>"].tags["mcp-modified"]` in the `discover-spaces` result.

## Schema mutation — when to force-refresh

After any operation that creates or modifies a **type, property, tag, or space** (not ordinary objects), call `discover-spaces(force_refresh=true)` before continuing, so subsequent ID lookups reflect the updated schema.
Do **not** call `force_refresh` speculatively — it is a network round-trip for every space.
Add `mcp-modified` tag after every `create`/`update` on page-like objects.
ID: `spaces["<Space>"].tags["mcp-modified"]` in *Discover Info*.

## Search

`API-search-global` searches across all spaces. Prefer it for discovery when you do not know which space contains an object.
Use `API-search-global` when the containing space is unknown.

## Error handling

- If an API tool returns `{ "httpStatus": 404, ... }`, the object or resource does not exist — do not retry with the same arguments.
- If an ID-dependent call fails, re-run `discover-spaces` (with `force_refresh=true` if schema may have changed) and re-resolve the ID before retrying.
- Do not surface raw JSON error payloads to the user — summarise the failure and state what you will do next.
- `httpStatus: 404` — do not retry with same args.
- Never surface raw JSON errors to the user — summarise and state next action.

## What not to do
## Never

- Do not call `discover-spaces` more than once per session unless a schema mutation has occurred or a stale-ID error is encountered.
- Do not call list-spaces, list-types, list-tags, or list-properties to resolve IDs — the `discover-spaces` result already contains everything needed.
- Do not create objects without first confirming the correct space and type.
- Do not perform bulk deletions, bulk moves, or any destructive operation on more than one object at a time without explicit per-object user confirmation.
- Call `discover-spaces` more than once per session without a schema mutation or 404.
- Call list-spaces, list-types, list-tags, or list-properties to resolve IDs.
- Create objects without confirming space and type first.
- Perform bulk destructive operations without explicit per-object user confirmation.
2 changes: 1 addition & 1 deletion src/init-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function loadOpenApiSpec(): Promise<OpenAPIV3.Document> {
export async function initProxy() {
console.error("Initializing Anytype MCP Server...");
const openApiSpec = await loadOpenApiSpec();
const proxy = new MCPProxy("Anytype API", openApiSpec);
const proxy = await MCPProxy.create("Anytype API", openApiSpec);
const { transport: transportConfig } = getConfig();
if (transportConfig.type === "http") {
const { host, port, passthroughHeaders } = transportConfig;
Expand Down
20 changes: 13 additions & 7 deletions src/mcp/__tests__/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ vi.mock("../../client/http-client", async (importOriginal) => {
return { ...real, HttpClient: MockHttpClient };
});
vi.mock("@modelcontextprotocol/sdk/server/index.js");
vi.mock("../../utils/resolveInstructions", () => ({
resolveInstructions: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../utils/getPlainAxios", () => ({
getPlainAxios: vi.fn().mockReturnValue({}),
}));

describe("MCPProxy", () => {
let proxy: MCPProxy;
Expand Down Expand Up @@ -46,10 +52,10 @@ describe("MCPProxy", () => {
const getMockExecuteOperation = () =>
vi.mocked(HttpClient).mock.results.at(-1)?.value.executeOperation as ReturnType<typeof vi.fn>;

beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
mockOpenApiSpec = createMockOpenApiSpec();
proxy = new MCPProxy("test-proxy", mockOpenApiSpec);
proxy = await MCPProxy.create("test-proxy", mockOpenApiSpec);
});

describe("listTools handler", () => {
Expand All @@ -72,7 +78,7 @@ describe("MCPProxy", () => {
},
},
});
const testProxy = new MCPProxy("test-proxy", specWithLongName);
const testProxy = await MCPProxy.create("test-proxy", specWithLongName);
const [listToolsHandler] = getHandlers(testProxy);
const result = await listToolsHandler();

Expand Down Expand Up @@ -177,10 +183,10 @@ describe("MCPProxy", () => {
});

describe("openApiHeaders", () => {
it("should pass httpClient config from getConfig() to HttpClient", () => {
it("should pass httpClient config from getConfig() to HttpClient", async () => {
// Config parsing is tested in proxy-config.test.ts.
// Here we only verify that MCPProxy forwards getConfig().httpClient as-is.
new MCPProxy("test-proxy", mockOpenApiSpec);
await MCPProxy.create("test-proxy", mockOpenApiSpec);
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({ headers: expect.any(Object) }),
expect.anything(),
Expand All @@ -191,8 +197,8 @@ describe("MCPProxy", () => {
describe("base URL integration", () => {
// Base URL resolution priority is tested in base-url.test.ts.
// Here we verify MCPProxy passes getConfig().httpClient to HttpClient (baseUrl may be undefined).
it("should pass httpClient config to HttpClient", () => {
new MCPProxy("test-proxy", mockOpenApiSpec);
it("should pass httpClient config to HttpClient", async () => {
await MCPProxy.create("test-proxy", mockOpenApiSpec);
expect(HttpClient).toHaveBeenCalledWith(
expect.objectContaining({ headers: expect.any(Object) }),
expect.anything(),
Expand Down
17 changes: 14 additions & 3 deletions src/mcp/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import pkg from "../../package.json";
import { HttpClient, HttpClientConnectionError, HttpClientError } from "../client/http-client";
import { OpenAPIToMCPConverter, type ToolMethod } from "../openapi/parser";
import { getConfig } from "../utils/config";
import { getPlainAxios } from "../utils/getPlainAxios";
import { resolveInstructions } from "../utils/resolveInstructions";
import {
DISCOVER_SPACES_TOOL_NAME,
Expand Down Expand Up @@ -35,13 +36,13 @@ export class MCPProxy {
};
private discoverSpaces: (params: DiscoverSpacesParams) => Promise<{ content: Array<{ type: "text"; text: string }> }>;

constructor(name: string, openApiSpec: OpenAPIV3.Document) {
private constructor(name: string, openApiSpec: OpenAPIV3.Document, instructions: string | undefined) {
this.state = {
toolsLogged: false,
serverInfo: { name, version: pkg.version, description: `Anytype API proxy (spec v${openApiSpec.info.version})` },
serverOptions: {
capabilities: { tools: {} },
instructions: resolveInstructions(getConfig().instructions),
instructions,
},
};
this.server = new Server(this.state.serverInfo, this.state.serverOptions);
Expand Down Expand Up @@ -198,6 +199,16 @@ export class MCPProxy {
return name.slice(0, 64);
}

/**
* Async factory: resolves instructions (including anytype:// fetches) before
* constructing the MCPProxy instance.
*/
static async create(name: string, openApiSpec: OpenAPIV3.Document): Promise<MCPProxy> {
const config = getConfig();
const instructions = await resolveInstructions(config.instructions, getPlainAxios(config.httpClient));
return new MCPProxy(name, openApiSpec, instructions);
}

async connect(transport: Transport) {
// The SDK will handle stdio communication
await this.server.connect(transport);
Expand All @@ -212,7 +223,7 @@ export class MCPProxy {
clone(requestHeaders?: Record<string, string>): MCPProxy {
const instance = Object.create(MCPProxy.prototype) as MCPProxy;
instance.state = this.state; // shared reference — mutations visible across clones
instance.server = new Server(this.state.serverInfo, this.state.serverOptions);
instance.server = new Server(this.state.serverInfo, this.state.serverOptions); // instructions already resolved in state
instance.httpClient = requestHeaders ? this.httpClient.withHeaders(requestHeaders) : this.httpClient;
instance.tools = this.tools;
instance.openApiLookup = this.openApiLookup;
Expand Down
11 changes: 5 additions & 6 deletions src/mcp/tools/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,13 @@ export async function fetchDiscovery(

export const DISCOVER_SPACES_TOOL_NAME = "discover-spaces";

const DISCOVER_SPACES_TOOL_DESCRIPTION = `Returns a JSON structure describing all available Spaces and their Types with Properties and Tags, with respective IDs and keys.
Call this tool FIRST in any session and cache the result mentally for the session. Do not call it again unless you have performed a schema-mutating operation (creating or modifying a type, property, tag, or space).
Use bracket-notation \`path\` to narrow the result and avoid re-fetching: \`discover-spaces(path='spaces["Career"].types["JobApplication"].properties["Stage"].select')\``;

export const discoverSpacesTool: Tool = {
name: DISCOVER_SPACES_TOOL_NAME,
description: [
"Returns a JSON description of all Anytype spaces: space IDs, types, properties, tags, and select option IDs.",
"Call this tool FIRST in any session to resolve IDs before calling create/update/search tools.",
'Optional bracket-notation path returns only a sub-tree: spaces["Career"].tags',
"Set force_refresh=true only after a schema-mutating operation (creating/modifying a type, tag, or space).",
].join("\n"),
description: DISCOVER_SPACES_TOOL_DESCRIPTION,
inputSchema: {
type: "object",
properties: {
Expand Down
Loading