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
76 changes: 68 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,74 @@ npm install -g @anyproto/anytype-mcp

## Environment Variables

| Variable | Default | Description |
| ------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OPENAPI_MCP_HEADERS` | — | JSON object of headers forwarded to the Anytype API on every request. Required for auth: `{"Authorization":"Bearer <key>", "Anytype-Version":"2025-11-08"}` |
| `ANYTYPE_API_BASE_URL` | `http://127.0.0.1:31009` | Anytype API base URL. Set to `http://localhost:31012` for `anytype-cli`. |
| `MCP_TRANSPORT` | `stdio` | Transport mode. Set to `http` to enable the Streamable HTTP server. |
| `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. |
| Variable | Default | Description |
| ------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `OPENAPI_MCP_HEADERS` | — | JSON object of headers forwarded to the Anytype API on every request. Required for auth: `{"Authorization":"Bearer <key>", "Anytype-Version":"2025-11-08"}` |
| `ANYTYPE_API_BASE_URL` | `http://127.0.0.1:31009` | Anytype API base URL. Set to `http://localhost:31012` for `anytype-cli`. |
| `MCP_TRANSPORT` | `stdio` | Transport mode. Set to `http` to enable the Streamable HTTP server. |
| `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. |
| `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). |



### 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.

#### Path narrowing

Instead of fetching the full structure every time, you can request a sub-tree using bracket-notation path syntax:
```
discover-spaces(path='spaces["My Space"].tags')
discover-spaces(path='spaces["My Space"].types["Task"].properties["Status"].select')
```

#### Filtering spaces and types

By default all spaces and types are included.
Use `DISCOVERY_TOOL_CONFIG` to limit the scope:
```json
{
"mcpServers": {
"anytype": {
"command": "npx",
"args": ["-y", "@anyproto/anytype-mcp"],
"env": {
"OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer <YOUR_API_KEY>\", \"Anytype-Version\":\"2025-11-08\"}",
"DISCOVERY_TOOL_CONFIG": "{\"spaces\":{\"Work\":{\"types\":{\"Task\":{},\"Project\":{}}},\"Personal\":{}}}"
}
}
}
}
```

For non-trivial configs, use a file reference instead of an inline JSON string:
```json
"DISCOVERY_TOOL_CONFIG": "{file:path/to/discovery-config.json}"
```

`discovery-config.json`:
```json
{
"ttlMs": 300000,
"spaces": {
"Work": {
"types": {
"Task": {},
"Project": {}
}
},
"Personal": {}
}
}
```

#### Cache

Results are cached for 5 minutes by default. Set `ttlMs` in `DISCOVERY_TOOL_CONFIG` to adjust. The AI assistant will call `discover-spaces(force_refresh=true)` automatically after schema-mutating operations (creating or modifying a type, property, tag, or space).

### Custom API Base URL

Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default [
...tseslint.configs.recommended.rules,
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-undef": "off", // https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
},
},
{
Expand Down
75 changes: 75 additions & 0 deletions instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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.

## 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).

## ID resolution

- **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"`.

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

```
discover-spaces(path='spaces["Career"].tags')
discover-spaces(path='spaces["Career"].types["JobApplication"].properties["Stage"].select')
```

## Creating and updating objects

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

Examples by format:

| Format | Payload |
| -------------- | -------------------------------------------------- |
| `text` | `{ key: "company_name", text: "Acme" }` |
| `url` | `{ key: "vacancy_url", url: "https://..." }` |
| `number` | `{ key: "salary", number: 120000 }` |
| `date` | `{ key: "applied_date", date: "2026-03-13" }` |
| `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.

## 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.

## 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.

## Search

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

## 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.

## What not to do

- 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.
74 changes: 68 additions & 6 deletions scripts/__tests__/start-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadOpenApiSpec, ValidationError } from "../../src/init-server";
import { ensureAnytypeRunning } from "../../src/utils/anytype-launcher";
import { overrideSpecPath } from "../../src/utils/base-url";

// Reset specPathOverride after each test to avoid inter-test contamination
afterEach(() => overrideSpecPath(undefined));

// Mock fs and axios
// Mock fs, axios, and the launcher
vi.mock("node:fs");
vi.mock("axios");
vi.mock("@modelcontextprotocol/sdk/server/stdio.js");
vi.mock("../../src/utils/anytype-launcher", () => ({
ensureAnytypeRunning: vi.fn().mockResolvedValue(false),
}));

// Create a mock Server class with proper prototype methods
const mockSetRequestHandler = vi.fn();
Expand Down Expand Up @@ -96,7 +100,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to read OpenAPI specification file:", expect.any(String));
expect(console.error).toHaveBeenCalledWith(
"Failed to read OpenAPI specification file from ./non-existent.json:",
"ENOENT: no such file or directory",
);
expect(mockExit).toHaveBeenCalledWith(1);
});

Expand All @@ -110,7 +117,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to parse OpenAPI specification:", expect.any(String));
expect(console.error).toHaveBeenCalledWith(
"Failed to parse OpenAPI specification from ./invalid.json:",
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);
});

Expand All @@ -136,7 +146,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to parse OpenAPI specification:", expect.any(String));
expect(console.error).toHaveBeenCalledWith(
"Failed to parse OpenAPI specification from ./invalid.yaml:",
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);
});
});
Expand All @@ -163,7 +176,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to fetch OpenAPI specification from URL:", "Network Error");
expect(console.error).toHaveBeenCalledWith(
"Failed to fetch OpenAPI specification from http://example.com/api-spec.json:",
"Network Error",
);
expect(mockExit).toHaveBeenCalledWith(1);
});

Expand All @@ -177,7 +193,10 @@ describe("loadOpenApiSpec", () => {

await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith("Failed to parse OpenAPI specification:", expect.any(String));
expect(console.error).toHaveBeenCalledWith(
"Failed to parse OpenAPI specification from http://example.com/api-spec.json:",
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);
});

Expand All @@ -193,6 +212,49 @@ describe("loadOpenApiSpec", () => {
expect(axios.get).toHaveBeenCalledWith("http://example.com/api-spec.yaml");
});
});

describe("ECONNREFUSED auto-launch retry", () => {
const econnRefused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:31009"), { code: "ECONNREFUSED" });

it("launches Anytype and retries when ECONNREFUSED on a local URL", async () => {
vi.mocked(ensureAnytypeRunning).mockResolvedValue(true);
// First call throws, second succeeds after launch
vi.mocked(axios.get).mockRejectedValueOnce(econnRefused).mockResolvedValueOnce({ data: validOpenApiSpec });
overrideSpecPath("http://127.0.0.1:31009/docs/openapi.json");

const result = await loadOpenApiSpec();

expect(ensureAnytypeRunning).toHaveBeenCalledWith("http://127.0.0.1:31009");
expect(result).toEqual(validOpenApiSpec);
});

it("exits with 1 when ECONNREFUSED and ensureAnytypeRunning returns false", async () => {
vi.mocked(ensureAnytypeRunning).mockResolvedValue(false);
vi.mocked(axios.get).mockRejectedValueOnce(econnRefused);
overrideSpecPath("http://127.0.0.1:31009/docs/openapi.json");

const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Cannot connect to Anytype API"));
expect(mockExit).toHaveBeenCalledWith(1);
});

it("exits with 1 when Anytype launches but API is still unreachable on retry", async () => {
vi.mocked(ensureAnytypeRunning).mockResolvedValue(true);
vi.mocked(axios.get).mockRejectedValue(econnRefused); // both calls fail
overrideSpecPath("http://127.0.0.1:31009/docs/openapi.json");

const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
await loadOpenApiSpec();

expect(console.error).toHaveBeenCalledWith(
"Failed to parse OpenAPI specification from http://127.0.0.1:31009/docs/openapi.json:",
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);
});
});
});

describe("main", () => {
Expand Down
7 changes: 6 additions & 1 deletion scripts/build-cli.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import * as esbuild from "esbuild";
import { readFileSync } from "fs";
import { chmod } from "fs/promises";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));

async function build() {
const instructions = readFileSync(join(__dirname, "../instructions.md"), "utf8");

await esbuild.build({
entryPoints: [join(__dirname, "start-server.ts")],
bundle: true,
Expand All @@ -18,6 +20,9 @@ async function build() {
js: "#!/usr/bin/env node\nimport { createRequire } from 'module';const require = createRequire(import.meta.url);", // see https://github.com/evanw/esbuild/pull/2067
},
external: ["util"],
define: {
__BUNDLED_INSTRUCTIONS__: JSON.stringify(instructions),
},
});

// Make the output file executable
Expand Down
Loading