diff --git a/README.md b/README.md index 1ea8be9..289e956 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,23 @@ This is an MCP server for [Attio](https://attio.com/), the AI-native CRM. It all #### Current Capabilities -- [x] reading company records -- [x] reading company notes -- [x] writing company notes -- [ ] other activities +**Resources** + +- List companies (by last interaction), read company by URI (`attio://companies/{id}`) + +**Tools** + +- **Companies (convenience):** search-companies, read-company-details, read-company-notes, create-company-note +- **Objects & schema:** list-objects, get-object, list-attributes, create-attribute, update-attribute +- **Records (any object):** query-records, get-record, create-record, update-record, delete-record +- **Tasks:** list-tasks, create-task, get-task, update-task +- **Meetings:** list-meetings, get-meeting +- **Call recordings:** list-call-recordings, get-call-recording +- **Comments:** create-comment (record, entry, or thread reply), list-threads, get-comment, delete-comment + +**Required scopes (API key / OAuth)** + +For full functionality, the Attio token should have: `object_configuration:read` (and `read-write` for create/update attribute), `record_permission:read` and `record_permission:read-write`, `comment:read` and `comment:read-write`, `task:read` and `task:read-write`, `note:read` and `note:read-write`, `meeting:read`, `call_recording:read`, `user_management:read`. When using a bearer token from the API Explorer, ensure these scopes are enabled. ## Usage diff --git a/check-connection.mjs b/check-connection.mjs new file mode 100644 index 0000000..c7ec314 --- /dev/null +++ b/check-connection.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { readFileSync, existsSync } from "fs"; +import { resolve } from "path"; + +// Load .env if present +const envPath = resolve(process.cwd(), ".env"); +if (existsSync(envPath)) { + for (const line of readFileSync(envPath, "utf8").split("\n")) { + const m = line.match(/^([^#=]+)=(.*)$/); + if (m) process.env[m[1].trim()] = m[2].trim(); + } +} + +import axios from "axios"; + +const api = axios.create({ + baseURL: "https://api.attio.com/v2", + headers: { + Authorization: `Bearer ${process.env.ATTIO_API_KEY}`, + "Content-Type": "application/json", + }, +}); + +try { + const { data } = await api.post("/objects/companies/records/query", { limit: 1 }); + const count = data.data?.length ?? 0; + console.log("✓ Connected to Attio. API key is valid."); + console.log(` (Sample query returned ${count} company/companies.)`); +} catch (err) { + const status = err.response?.status; + const body = err.response?.data; + if (status === 401) { + console.error("✗ Connection failed: Invalid or missing API key (401)."); + } else if (status) { + console.error("✗ Connection failed:", status, body ?? err.message); + } else { + console.error("✗ Connection failed:", err.message); + } + process.exit(1); +} diff --git a/docs/EXPANSION_PLAN.md b/docs/EXPANSION_PLAN.md new file mode 100644 index 0000000..d8b7b76 --- /dev/null +++ b/docs/EXPANSION_PLAN.md @@ -0,0 +1,172 @@ +# MCP Server Expansion Plan + +## Implementation status + +| Category | Tools | Status | +|----------|--------|--------| +| Objects & schema | list-objects, get-object, list-attributes, create-attribute, update-attribute | Done | +| Records | query-records, get-record, create-record, update-record, delete-record | Done | +| Comments | create-comment (record/entry/thread), list-threads, get-comment, delete-comment | Done | +| Tasks | list-tasks, create-task, get-task, update-task | Done | +| Meetings | list-meetings, get-meeting | Done | +| Call recordings | list-call-recordings, get-call-recording | Done | +| Optional (not implemented) | assert-record, meetings as resources, transcript for call recording, select/status options | Pending / deferred | + +--- + +This document outlines how to add the following to the Attio MCP server: + +- **Objects & attributes** – create and manage objects and their attributes +- **Write to all record types** – generic CRUD for any object (companies, people, deals, custom) +- **Comments** – create and view comments on records/entries/threads +- **Tasks** – list, create, and manage tasks +- **Meetings** – list and view meetings +- **Call recordings** – list and view call recordings (and transcripts) for meetings + +--- + +## 1. Objects & attributes + +**Attio API** + +- **List objects:** `GET /v2/objects` – returns all objects (people, companies, deals, custom). Scope: `object_configuration:read`. +- **Get object:** `GET /v2/objects/{object}` – single object by slug or UUID. Scope: `object_configuration:read`. +- **List attributes:** `GET /v2/objects/{object}/attributes` – attributes for an object. Scope: `object_configuration:read`. +- **Create attribute:** `POST /v2/objects/{object}/attributes`. Scope: `object_configuration:read-write`. +- **Update attribute:** `PATCH /v2/objects/{object}/attributes/{attribute}`. Scope: `object_configuration:read-write`. + +**MCP additions** + +- **Tools:** `list-objects`, `get-object`, `list-attributes`, `create-attribute`, `update-attribute` (and optionally list/create/update select options and statuses). + +--- + +## 2. Generic record CRUD (all record types) + +**Attio API** + +- **Query records:** `POST /v2/objects/{object}/records/query` – filter, sort, limit, offset. Scope: `record_permission:read`, `object_configuration:read`. +- **Get record:** `GET /v2/objects/{object}/records/{record_id}`. Scope: same. +- **Create record:** `POST /v2/objects/{object}/records` – body `{ data: { values: { "attribute_slug": value, ... } } }`. Scope: `record_permission:read-write`, `object_configuration:read`. +- **Update record:** `PATCH /v2/objects/{object}/records/{record_id}` – same values shape. Scope: same. +- **Assert record:** `PUT /v2/objects/{object}/records` – create or update by unique attribute. Optional for “upsert” behavior. +- **Delete record:** `DELETE /v2/objects/{object}/records/{record_id}`. Scope: `record_permission:read-write`. + +**MCP additions** + +- **Tools:** + - `query-records` – params: `object` (slug e.g. `people`, `companies`, `deals`), optional `filter`, `sorts`, `limit`, `offset`. + - `get-record` – params: `object`, `recordId`. + - `create-record` – params: `object`, `values` (JSON object keyed by attribute api_slug). + - `update-record` – params: `object`, `recordId`, `values`. + - `delete-record` – params: `object`, `recordId` (use with care; optional). + +Existing company-specific tools can remain for convenience; generic tools cover all objects. + +--- + +## 3. Comments + +**Attio API** + +- **Create comment:** `POST /v2/comments` – body can target: + - **Record:** `data: { format, content, author: { type, id }, record: { object, record_id } }`. + - **Entry:** `data: { ..., entry: { list, entry_id } }`. + - **Thread (reply):** `data: { ..., thread_id }`. +- **Get comment:** `GET /v2/comments/{comment_id}`. Scope: `comment:read`. +- **List threads** (e.g. for a record): `GET /v2/threads?parent_object=...&parent_record_id=...`. Scope: `comment:read`. +- **Delete comment:** `DELETE /v2/comments/{comment_id}`. Scope: `comment:read-write`. + +**MCP additions** + +- **Tools:** `create-comment` (on record or entry or thread), `list-threads` (for record/entry), `get-comment`, optional `delete-comment`. + +--- + +## 4. Tasks + +**Attio API** + +- **List tasks:** `GET /v2/tasks` – query params: `limit`, `offset`, `sort`, `linked_object`, `linked_record_id`, `assignee`, `is_completed`. Scope: `task:read`, `object_configuration:read`, `record_permission:read`, `user_management:read`. +- **Create task:** `POST /v2/tasks` – body: content (plaintext), format, optional `deadline_at`, `is_completed`, `linked_records`, `assignees`. Scope: `task:read-write`, etc. +- **Get task:** `GET /v2/tasks/{task_id}`. Scope: same as list. +- **Update task:** `PATCH /v2/tasks/{task_id}` – e.g. mark complete, change deadline. Scope: `task:read-write`. + +**MCP additions** + +- **Tools:** `list-tasks`, `create-task`, `get-task`, `update-task` (e.g. set `is_completed`). + +--- + +## 5. Meetings + +**Attio API** + +- **List meetings:** `GET /v2/meetings` – params: `limit`, `cursor`, `linked_object`, `linked_record_id`, `participants`, `sort`, `ends_from`, `starts_before`, `timezone`. Scope: `meeting:read`, `record_permission:read`. +- **Get meeting:** `GET /v2/meetings/{meeting_id}`. Scope: same. + +**MCP additions** + +- **Tools:** `list-meetings`, `get-meeting`. Optional **resources:** e.g. “upcoming meetings” as a resource the LLM can read. + +--- + +## 6. Call recordings + +**Attio API** + +- **List call recordings:** `GET /v2/meetings/{meeting_id}/call_recordings`. Scope: `meeting:read`, `call_recording:read`. +- **Get call recording:** `GET /v2/meetings/{meeting_id}/call_recordings/{call_recording_id}`. Scope: same. +- Transcripts/speakers are typically part of the call recording or separate transcript endpoints. + +**MCP additions** + +- **Tools:** `list-call-recordings` (params: `meetingId`), `get-call-recording` (params: `meetingId`, `callRecordingId`). Optionally a tool to get transcript for a recording. + +--- + +## Implementation order + +1. **Phase 1 – Generic records + discovery** + Add `list-objects`, `list-attributes`, `query-records`, `get-record`, `create-record`, `update-record`. This gives “write to all record types” and the schema context (objects/attributes). + +2. **Phase 2 – Tasks, meetings, call recordings** + Add `list-tasks`, `create-task`, `get-task`, `update-task`; `list-meetings`, `get-meeting`; `list-call-recordings`, `get-call-recording`. Pure read (+ task create/update) with clear APIs. + +3. **Phase 3 – Comments** + Add `list-threads`, `create-comment`, `get-comment` (and optionally `delete-comment`). Comments require author (workspace member ID) for create. + +4. **Phase 4 – Objects/attributes management (optional)** + Add `create-attribute`, `update-attribute`, and optionally list/create/update select options and statuses if you need the MCP to change schema, not just data. + +--- + +## OAuth scopes (API key / token) + +For full functionality, the Attio token should have at least: + +- `object_configuration:read` (and `read-write` if creating/updating attributes) +- `record_permission:read` and `record_permission:read-write` +- `comment:read` and `comment:read-write` +- `task:read` and `task:read-write` +- `note:read` and `note:read-write` (already used for notes) +- `meeting:read` (and `meeting:read-write` if you add meeting create/update later) +- `call_recording:read` (and `call_recording:read-write` if you add create) +- `user_management:read` (for tasks and comment author resolution) + +If using a bearer token from the API Explorer, ensure these scopes are enabled for that token (or use OAuth and request them). + +--- + +## Code structure suggestion + +- Keep a single `src/index.ts` for a small server, or split by domain: + - `src/handlers/records.ts` – generic record CRUD + query + - `src/handlers/objects.ts` – list/get objects, list/create/update attributes + - `src/handlers/comments.ts` – comments and threads + - `src/handlers/tasks.ts` – tasks + - `src/handlers/meetings.ts` – meetings and call recordings +- Share one Axios instance and `createErrorResult` in a small `src/api.ts` or at the top of `index.ts`. +- Register all tools in `ListToolsRequestSchema` and route in `CallToolRequestSchema` by tool name (or by prefix if you group by domain). + +This plan gives a clear path to “create and manage objects/attributes, write to all record types, comment, create tasks, and look at meetings and call recordings” in the MCP. diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..0fdb28b --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,9 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testPathIgnorePatterns: ["/dist/"], + testMatch: ["**/__tests__/**/*.test.ts"], + modulePathIgnorePatterns: ["/dist/"], + moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1" }, +}; diff --git a/package-lock.json b/package-lock.json index 262ef49..7910f83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/jest": "^29.5.14", + "dotenv": "^17.3.1", "jest": "^29.7.0", "ts-jest": "^29.2.5", "tsx": "^4.19.2" @@ -1989,6 +1990,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", diff --git a/package.json b/package.json index f3d731c..c39c9b6 100644 --- a/package.json +++ b/package.json @@ -1,43 +1 @@ -{ - "name": "attio-mcp-server", - "version": "0.0.2", - "description": "A Model Context Protocol server that connects Attio to LLMs", - "main": "dist/index.js", - "module": "dist/index.js", - "type": "module", - "access": "public", - "bin": { - "attio-mcp-server": "dist/index.js" - }, - "scripts": { - "clean": "shx rm -rf dist", - "build": "tsc", - "postbuild": "shx chmod +x dist/*.js", - "check": "tsc --noEmit", - "build:watch": "tsc --watch" - }, - "files": [ - "dist" - ], - "dependencies": { - "@modelcontextprotocol/sdk": "^1.4.1", - "axios": "^1.7.9", - "shx": "^0.3.4", - "typescript": "^5.7.2" - }, - "author": "@hmk", - "license": "BSD-3-Clause", - "devDependencies": { - "@types/jest": "^29.5.14", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", - "tsx": "^4.19.2" - }, - "jest": { - "preset": "ts-jest", - "testEnvironment": "node", - "testPathIgnorePatterns": [ - "/dist/" - ] - } -} +{"name":"attio-mcp-server","version":"0.0.2","description":"A Model Context Protocol server that connects Attio to LLMs","main":"dist/index.js","module":"dist/index.js","type":"module","access":"public","bin":{"attio-mcp-server":"dist/index.js"},"scripts":{"clean":"shx rm -rf dist","build":"tsc","postbuild":"shx chmod +x dist/*.js","check":"tsc --noEmit","build:watch":"tsc --watch","test":"jest","test:live":"jest --testPathPattern=live"},"files":["dist"],"dependencies":{"@modelcontextprotocol/sdk":"^1.4.1","axios":"^1.7.9","shx":"^0.3.4","typescript":"^5.7.2"},"author":"@hmk","license":"BSD-3-Clause","devDependencies":{"@types/jest":"^29.5.14","dotenv":"^17.3.1","jest":"^29.7.0","ts-jest":"^29.2.5","tsx":"^4.19.2"}} \ No newline at end of file diff --git a/src/__tests__/live.test.ts b/src/__tests__/live.test.ts new file mode 100644 index 0000000..e696f48 --- /dev/null +++ b/src/__tests__/live.test.ts @@ -0,0 +1,277 @@ +/** + * Live tests against the Attio API (demo workspace). + * Requires ATTIO_API_KEY (in env or .env). Suite is skipped when the key is missing. + */ +import "dotenv/config"; +import axios from "axios"; +import { + getListToolsResponse, + handleListResources, + handleReadResource, + handleToolCall, +} from "../handlers.js"; + +const hasApiKey = !!process.env.ATTIO_API_KEY; + +function createApi(): ReturnType { + return axios.create({ + baseURL: "https://api.attio.com/v2", + headers: { + Authorization: `Bearer ${process.env.ATTIO_API_KEY}`, + "Content-Type": "application/json", + }, + }); +} + +async function callTool( + api: ReturnType, + name: string, + args: Record = {} +) { + return handleToolCall(api, { params: { name, arguments: args } }); +} + +(hasApiKey ? describe : describe.skip)("Live MCP tools (demo workspace)", () => { + let api: ReturnType; + + beforeAll(() => { + if (!hasApiKey) return; + api = createApi(); + }); + + describe("ListTools", () => { + it("returns 26 tools with name, description, inputSchema", () => { + const { tools } = getListToolsResponse(); + expect(tools).toHaveLength(26); + for (const tool of tools) { + expect(tool).toHaveProperty("name"); + expect(tool).toHaveProperty("description"); + expect(tool).toHaveProperty("inputSchema"); + expect(typeof tool.name).toBe("string"); + expect(typeof tool.description).toBe("string"); + expect(tool.inputSchema).toEqual(expect.any(Object)); + } + }); + }); + + describe("Resources", () => { + it("ListResources returns resources with uri and name", async () => { + const result = await handleListResources(api!, { params: {} }); + expect(result).toHaveProperty("resources"); + const res = (result as { resources?: unknown[] }).resources; + expect(Array.isArray(res)).toBe(true); + for (const r of res as Array<{ uri?: string; name?: string }>) { + expect(r).toHaveProperty("uri"); + expect(r).toHaveProperty("name"); + } + }); + + it("ReadResource returns contents for a company URI", async () => { + const list = await handleListResources(api!, { params: {} }); + const resources = (list as { resources?: Array<{ uri: string }> }).resources ?? []; + if (resources.length === 0) { + return; // skip if no companies + } + const uri = resources[0].uri; + const result = await handleReadResource(api!, { params: { uri } }); + expect(result).toHaveProperty("contents"); + const contents = (result as { contents?: Array<{ uri: string; text: string; mimeType: string }> }).contents ?? []; + expect(Array.isArray(contents)).toBe(true); + expect(contents[0]).toMatchObject({ + uri, + mimeType: "application/json", + }); + }); + }); + + describe("Companies", () => { + it("search-companies returns results for query", async () => { + const result = await callTool(api!, "search-companies", { query: "test" }); + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + }); + + it("read-company-details returns details for a company URI", async () => { + const search = await callTool(api!, "search-companies", { query: "test" }); + if (search.isError || !search.content[0].text) return; + const match = search.content[0].text.match(/attio:\/\/companies\/([a-f0-9-]+)/); + if (!match) return; + const uri = `attio://companies/${match[1]}`; + const result = await callTool(api!, "read-company-details", { uri }); + expect(result.isError).toBe(false); + }); + }); + + describe("Objects and attributes", () => { + it("list-objects returns objects", async () => { + const result = await callTool(api!, "list-objects", {}); + expect(result.isError).toBe(false); + expect(JSON.parse(result.content[0].text)).toEqual(expect.any(Array)); + }); + + it("get-object(people) returns object schema", async () => { + const result = await callTool(api!, "get-object", { object: "people" }); + expect(result.isError).toBe(false); + }); + + it("list-attributes(people) returns attributes", async () => { + const result = await callTool(api!, "list-attributes", { object: "people" }); + expect(result.isError).toBe(false); + }); + }); + + describe("Records", () => { + let createdRecordId: string | null = null; + const objectSlug = "companies"; + const createValues = { name: "MCP Test Company" }; + + it("query-records returns records", async () => { + const result = await callTool(api!, "query-records", { object: objectSlug, limit: 2 }); + expect(result.isError).toBe(false); + }); + + it("create-record creates a record", async () => { + const result = await callTool(api!, "create-record", { + object: objectSlug, + values: createValues, + }); + expect(result.isError).toBe(false); + const idMatch = result.content[0].text.match(/attio:\/\/companies\/([a-f0-9-]+)/); + if (idMatch) createdRecordId = idMatch[1]; + }); + + it("get-record returns the created record", async () => { + if (!createdRecordId) return; + const result = await callTool(api!, "get-record", { + object: objectSlug, + recordId: createdRecordId, + }); + expect(result.isError).toBe(false); + }); + + it("update-record updates the record", async () => { + if (!createdRecordId) return; + const result = await callTool(api!, "update-record", { + object: objectSlug, + recordId: createdRecordId, + values: { name: "MCP Test Company Updated" }, + }); + expect(result.isError).toBe(false); + }); + + it("delete-record cleans up", async () => { + if (!createdRecordId) return; + const result = await callTool(api!, "delete-record", { + object: objectSlug, + recordId: createdRecordId, + }); + expect(result.isError).toBe(false); + }); + }); + + describe("Tasks", () => { + it("list-tasks returns tasks", async () => { + const result = await callTool(api!, "list-tasks", { limit: 2 }); + expect(result.isError).toBe(false); + }); + + it("create-task creates a task", async () => { + const result = await callTool(api!, "create-task", { + content: "MCP test task", + }); + expect(result.isError).toBe(false); + }); + + it("get-task and update-task", async () => { + const list = await callTool(api!, "list-tasks", { limit: 1 }); + if (list.isError || !list.content[0].text) return; + const data = JSON.parse(list.content[0].text); + const taskId = data[0]?.id?.task_id; + if (!taskId) return; + const get = await callTool(api!, "get-task", { taskId }); + expect(get.isError).toBe(false); + const update = await callTool(api!, "update-task", { + taskId, + is_completed: false, + }); + expect(update.isError).toBe(false); + }); + }); + + describe("Meetings", () => { + it("list-meetings returns meetings", async () => { + const result = await callTool(api!, "list-meetings", { limit: 2 }); + expect(result.isError).toBe(false); + }); + }); + + describe("Comments", () => { + let commentId: string | null = null; + let recordId: string | null = null; + let workspaceMemberId: string | null = null; + + beforeAll(async () => { + const query = await callTool(api!, "query-records", { object: "companies", limit: 1 }); + if (query.isError || !query.content[0].text) return; + const records = JSON.parse(query.content[0].text); + const rec = records[0]; + if (rec?.id?.record_id) recordId = rec.id.record_id; + const peopleRes = await callTool(api!, "query-records", { object: "people", limit: 1 }); + if (peopleRes.isError || !peopleRes.content[0].text) return; + const people = JSON.parse(peopleRes.content[0].text); + const person = people[0]; + if (person?.id?.record_id) { + const getRec = await callTool(api!, "get-record", { + object: "people", + recordId: person.id.record_id, + }); + if (!getRec.isError && getRec.content[0].text) { + const doc = JSON.parse(getRec.content[0].text); + const owner = doc.data?.values?.owner?.value?.actor_id ?? doc.data?.values?.owner?.[0]?.value?.actor_id; + if (owner) workspaceMemberId = owner; + } + } + if (!workspaceMemberId) { + try { + const me = await api!.get("/workspace_members/me"); + workspaceMemberId = me.data?.data?.id?.workspace_member_id ?? null; + } catch { + // Endpoint may not exist or return 400 in some workspaces; leave null so comment tests skip + } + } + }); + + it("create-comment requires (object + record_id) or thread_id", async () => { + const result = await callTool(api!, "create-comment", { + content: "Test", + author_workspace_member_id: "00000000-0000-0000-0000-000000000000", + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/object \+ record_id|list \+ entry_id|thread_id/); + }); + + it("create-comment, list-threads, get-comment, delete-comment", async () => { + if (!recordId || !workspaceMemberId) return; + const create = await callTool(api!, "create-comment", { + content: "MCP test comment", + author_workspace_member_id: workspaceMemberId, + object: "companies", + record_id: recordId, + }); + expect(create.isError).toBe(false); + const idMatch = create.content[0].text.match(/Comment created: ([a-f0-9-]+)/); + if (idMatch) commentId = idMatch[1]; + if (!commentId) return; + const listThreads = await callTool(api!, "list-threads", { + object: "companies", + record_id: recordId, + }); + expect(listThreads.isError).toBe(false); + const getComment = await callTool(api!, "get-comment", { commentId }); + expect(getComment.isError).toBe(false); + const del = await callTool(api!, "delete-comment", { commentId }); + expect(del.isError).toBe(false); + }); + }); +}); diff --git a/src/__tests__/unit.test.ts b/src/__tests__/unit.test.ts new file mode 100644 index 0000000..0025551 --- /dev/null +++ b/src/__tests__/unit.test.ts @@ -0,0 +1,129 @@ +/** + * Unit tests (no API). Tool schema and validation error messages. + */ +import { normalizeRecordValues } from "../api.js"; +import { getListToolsResponse, handleToolCall } from "../handlers.js"; +import { TOOLS } from "../tool-definitions.js"; + +describe("Tool list schema", () => { + it("exports 26 tools", () => { + expect(TOOLS).toHaveLength(26); + }); + + it("getListToolsResponse returns tools array with 26 items", () => { + const { tools } = getListToolsResponse(); + expect(tools).toHaveLength(26); + }); + + it("each tool has name, description, inputSchema", () => { + const { tools } = getListToolsResponse(); + for (const tool of tools) { + expect(tool).toHaveProperty("name"); + expect(tool).toHaveProperty("description"); + expect(tool).toHaveProperty("inputSchema"); + expect(typeof tool.name).toBe("string"); + expect(typeof tool.description).toBe("string"); + expect(tool.inputSchema).toEqual(expect.any(Object)); + expect(tool.inputSchema).toHaveProperty("type", "object"); + if ("required" in tool.inputSchema && Array.isArray((tool.inputSchema as { required?: string[] }).required)) { + expect((tool.inputSchema as { required: string[] }).required).toEqual(expect.any(Array)); + } + } + }); +}); + +describe("Validation errors (mock api)", () => { + const mockApi = { + get: async () => ({ data: {} }), + post: async () => ({ data: {} }), + patch: async () => ({ data: {} }), + delete: async () => {}, + } as unknown as ReturnType; + + it("create-comment without (object+record_id), (list+entry_id), or thread_id returns isError true and mentions target", async () => { + const result = await handleToolCall(mockApi, { + params: { + name: "create-comment", + arguments: { + content: "Hi", + author_workspace_member_id: "00000000-0000-0000-0000-000000000000", + }, + }, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/object \+ record_id|list \+ entry_id|thread_id/); + }); + + it("list-threads without (object+record_id) or (list+entry_id) returns isError true", async () => { + const result = await handleToolCall(mockApi, { + params: { + name: "list-threads", + arguments: {}, + }, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/object \+ record_id|list \+ entry_id/); + }); + + it("unknown tool name throws and is caught as isError true", async () => { + const result = await handleToolCall(mockApi, { + params: { name: "nonexistent-tool", arguments: {} }, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Tool not found|Error executing tool/); + }); +}); + +describe("normalizeRecordValues (PATCH/POST record payload)", () => { + it("wraps string in [{ value }]", () => { + expect(normalizeRecordValues({ name: "Acme" })).toEqual({ name: [{ value: "Acme" }] }); + }); + + it("wraps number and boolean in [{ value }]", () => { + expect(normalizeRecordValues({ count: 42 })).toEqual({ count: [{ value: 42 }] }); + expect(normalizeRecordValues({ active: true })).toEqual({ active: [{ value: true }] }); + }); + + it("converts array of primitives to array of value objects", () => { + expect(normalizeRecordValues({ tags: ["a", "b"] })).toEqual({ + tags: [{ value: "a" }, { value: "b" }], + }); + }); + + it("passes through array of value objects and strips attribute_type", () => { + expect( + normalizeRecordValues({ + description: [{ value: "Text", attribute_type: "text" }], + }) + ).toEqual({ description: [{ value: "Text" }] }); + }); + + it("wraps single value object in array and strips attribute_type", () => { + expect( + normalizeRecordValues({ + description: { value: "Only one", attribute_type: "text" }, + }) + ).toEqual({ description: [{ value: "Only one" }] }); + }); + + it("preserves email_address and other non-value keys", () => { + expect( + normalizeRecordValues({ + primary_email: [{ email_address: "hi@example.com" }], + }) + ).toEqual({ primary_email: [{ email_address: "hi@example.com" }] }); + }); + + it("skips attribute_type at top level", () => { + expect(normalizeRecordValues({ attribute_type: "text", name: "X" } as unknown as Record)).toEqual( + { name: [{ value: "X" }] } + ); + }); + + it("handles null/undefined by returning empty array for that key", () => { + expect(normalizeRecordValues({ name: null, desc: undefined } as unknown as Record)).toEqual({ + name: [], + desc: [], + }); + }); +}); diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..6dc5225 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,86 @@ +/** + * Keys that Attio does not accept in record value objects (e.g. in PATCH/POST body). + * Sending these causes 400 "Unrecognized key". + */ +const RECORD_VALUE_STRIP_KEYS = ["attribute_type", "type"]; + +function stripUnrecognizedKeys(obj: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (!RECORD_VALUE_STRIP_KEYS.includes(k)) out[k] = v; + } + return out; +} + +/** + * Normalize a single attribute's value into Attio's expected shape: an array of value objects. + * - Primitives (string, number, boolean) → [{ value: primitive }] + * - Array of primitives → [{ value: x }, ...] + * - Single object (e.g. { value: "..." } or { email_address: "..." }) → [object] with strip + * - Array of objects → each stripped of attribute_type/type, then passed through + * PATCH and POST record APIs expect "attribute_slug": [{ ... }] and reject attribute_type in the body. + */ +function normalizeValueEntry(entry: unknown): Array> { + if (entry === null || entry === undefined) return []; + if (Array.isArray(entry)) { + return entry.map((item) => { + if (item !== null && typeof item === "object" && !Array.isArray(item)) { + return stripUnrecognizedKeys(item as Record); + } + return { value: item }; + }); + } + if (typeof entry === "object" && !Array.isArray(entry)) { + return [stripUnrecognizedKeys(entry as Record)]; + } + return [{ value: entry }]; +} + +/** + * Normalize record values for Attio PATCH/POST record APIs. + * Ensures each attribute is an array of value objects and strips unrecognized keys (e.g. attribute_type). + */ +export function normalizeRecordValues(values: Record): Record>> { + const out: Record>> = {}; + for (const [key, val] of Object.entries(values)) { + if (RECORD_VALUE_STRIP_KEYS.includes(key)) continue; + out[key] = normalizeValueEntry(val); + } + return out; +} + +export type ToolResult = { + content: Array<{ type: "text"; text: string }>; + isError: boolean; + error?: unknown; +}; + +export function createErrorResult( + error: Error, + url: string, + method: string, + responseData: { status?: number; headers?: unknown; data?: unknown } +): ToolResult { + return { + content: [ + { + type: "text" as const, + text: + `ERROR: ${error.message}\n\n` + + `=== Request Details ===\n` + + `- Method: ${method}\n` + + `- URL: ${url}\n\n` + + `=== Response Details ===\n` + + `- Status: ${responseData.status}\n` + + `- Headers: ${JSON.stringify(responseData.headers || {}, null, 2)}\n` + + `- Data: ${JSON.stringify(responseData.data || {}, null, 2)}\n`, + }, + ], + isError: true, + error: { + code: responseData.status || 500, + message: error.message, + details: responseData.data ?? "Unknown error occurred", + }, + }; +} diff --git a/src/handlers.ts b/src/handlers.ts new file mode 100644 index 0000000..4a94bd6 --- /dev/null +++ b/src/handlers.ts @@ -0,0 +1,800 @@ +/** + * MCP request handlers (ListTools, ListResources, ReadResource, CallTool). + * Extracted for testability; index.ts delegates to these. + */ +import type { AxiosInstance } from "axios"; +import { createErrorResult, normalizeRecordValues } from "./api.js"; +import { TOOLS } from "./tool-definitions.js"; + +function getResponseData(error: unknown): { status?: number; headers?: unknown; data?: unknown } { + const err = error as { response?: { status?: number; headers?: unknown; data?: unknown } }; + return err?.response ?? { status: 500, headers: {}, data: {} }; +} + +export function getListToolsResponse() { + return { tools: TOOLS }; +} + +export async function handleListResources( + api: AxiosInstance, + _request: { params?: { uri?: string } } +) { + const path = "/objects/companies/records/query"; + try { + const response = await api.post(path, { + limit: 20, + sorts: [{ attribute: "last_interaction", field: "interacted_at", direction: "desc" }], + }); + const companies = response.data.data || []; + return { + resources: companies.map((company: { id?: { record_id?: string }; values?: { name?: Array<{ value?: string }> } }) => ({ + uri: `attio://companies/${company.id?.record_id}`, + name: company.values?.name?.[0]?.value || "Unknown Company", + mimeType: "application/json", + })), + description: `Found ${companies.length} companies that you have interacted with most recently`, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "POST", + getResponseData(error) + ); + } +} + +export async function handleReadResource( + api: AxiosInstance, + request: { params?: { uri?: string } } +) { + const uri = request.params?.uri ?? ""; + const companyId = uri.replace("attio://companies/", ""); + const path = `/objects/companies/records/${companyId}`; + try { + const response = await api.get(path); + return { + contents: [ + { + uri, + text: JSON.stringify(response.data, null, 2), + mimeType: "application/json", + }, + ], + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } +} + +export type CallToolRequest = { + params: { name: string; arguments?: Record }; +}; + +export async function handleToolCall( + api: AxiosInstance, + request: CallToolRequest +): Promise<{ content: Array<{ type: "text"; text: string }>; isError: boolean; error?: unknown }> { + const toolName = request.params.name; + const args = request.params.arguments ?? {}; + + try { + if (toolName === "search-companies") { + const query = args.query as string; + const path = "/objects/companies/records/query"; + try { + const response = await api.post(path, { + filter: { name: { $contains: query } }, + }); + const results = response.data.data || []; + const companies = (results as { values?: { name?: Array<{ value?: string }> }; id?: { record_id?: string } }[]) + .map((company) => { + const companyName = company.values?.name?.[0]?.value || "Unknown Company"; + const companyId = company.id?.record_id || "Record ID not found"; + return `${companyName}: attio://companies/${companyId}`; + }) + .join("\n"); + return { + content: [{ type: "text", text: `Found ${results.length} companies:\n${companies}` }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "POST", + getResponseData(error) + ); + } + } + + if (toolName === "read-company-details") { + const uri = args.uri as string; + const companyId = uri.replace("attio://companies/", ""); + const path = `/objects/companies/records/${companyId}`; + try { + const response = await api.get(path); + return { + content: [ + { + type: "text", + text: `Company details for ${companyId}:\n${JSON.stringify(response.data, null, 2)}`, + }, + ], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "read-company-notes") { + const uri = args.uri as string; + const limit = (args.limit as number) || 10; + const offset = (args.offset as number) || 0; + const companyId = uri.replace("attio://companies/", ""); + const path = `/notes?limit=${limit}&offset=${offset}&parent_object=companies&parent_record_id=${companyId}`; + try { + const response = await api.get(path); + const notes = response.data.data || []; + return { + content: [ + { + type: "text", + text: `Found ${notes.length} notes for company ${companyId}:\n${(notes as object[]).map((note) => JSON.stringify(note)).join("----------\n")}`, + }, + ], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "create-company-note") { + const companyId = args.companyId as string; + const noteTitle = args.noteTitle as string; + const noteText = args.noteText as string; + const url = "notes"; + try { + const response = await api.post(url, { + data: { + format: "plaintext", + parent_object: "companies", + parent_record_id: companyId, + title: `[AI] ${noteTitle}`, + content: noteText, + }, + }); + return { + content: [ + { + type: "text", + text: `Note added to company ${companyId}: attio://notes/${response.data?.id?.note_id}`, + }, + ], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + url, + "POST", + getResponseData(error) + ); + } + } + + if (toolName === "list-objects") { + const path = "/objects"; + try { + const response = await api.get(path); + const objects = response.data.data || []; + return { + content: [{ type: "text", text: JSON.stringify(objects, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "list-attributes") { + const objectSlug = args.object as string; + const path = `/objects/${objectSlug}/attributes`; + try { + const response = await api.get(path); + const attributes = response.data.data || []; + return { + content: [{ type: "text", text: JSON.stringify(attributes, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "query-records") { + const objectSlug = args.object as string; + const limit = (args.limit as number) ?? 20; + const offset = (args.offset as number) ?? 0; + const filter = args.filter as object | undefined; + const sorts = args.sorts as { attribute?: string; direction?: string }[] | undefined; + const path = `/objects/${objectSlug}/records/query`; + try { + const body: { limit: number; offset: number; filter?: object; sorts?: unknown[] } = { limit, offset }; + if (filter) body.filter = filter; + if (sorts?.length) body.sorts = sorts; + const response = await api.post(path, body); + const records = response.data.data || []; + return { + content: [{ type: "text", text: JSON.stringify(records, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "POST", + getResponseData(error) + ); + } + } + + if (toolName === "get-record") { + const objectSlug = args.object as string; + const recordId = args.recordId as string; + const path = `/objects/${objectSlug}/records/${recordId}`; + try { + const response = await api.get(path); + return { + content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "create-record") { + const objectSlug = args.object as string; + const values = normalizeRecordValues((args.values as Record) ?? {}); + const path = `/objects/${objectSlug}/records`; + try { + const response = await api.post(path, { data: { values } }); + const id = response.data?.data?.id; + const recordId = id?.record_id ?? response.data?.id?.record_id; + return { + content: [ + { + type: "text", + text: recordId + ? `Created record: attio://${objectSlug}/${recordId}\n${JSON.stringify(response.data, null, 2)}` + : JSON.stringify(response.data, null, 2), + }, + ], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "POST", + getResponseData(error) + ); + } + } + + if (toolName === "update-record") { + const objectSlug = args.object as string; + const recordId = args.recordId as string; + const values = normalizeRecordValues((args.values as Record) ?? {}); + const path = `/objects/${objectSlug}/records/${recordId}`; + try { + const response = await api.patch(path, { data: { values } }); + return { + content: [{ type: "text", text: `Updated record ${recordId}.\n${JSON.stringify(response.data, null, 2)}` }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "PATCH", + getResponseData(error) + ); + } + } + + if (toolName === "delete-record") { + const objectSlug = args.object as string; + const recordId = args.recordId as string; + const path = `/objects/${objectSlug}/records/${recordId}`; + try { + await api.delete(path); + return { + content: [{ type: "text", text: `Record ${recordId} deleted.` }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "DELETE", + getResponseData(error) + ); + } + } + + if (toolName === "get-object") { + const objectSlug = args.object as string; + const path = `/objects/${objectSlug}`; + try { + const response = await api.get(path); + return { + content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "create-attribute") { + const objectSlug = args.object as string; + const title = args.title as string; + const api_slug = args.api_slug as string; + const type = args.type as string; + const description = (args.description as string) ?? null; + const is_required = (args.is_required as boolean) ?? false; + const is_unique = (args.is_unique as boolean) ?? false; + const is_multiselect = (args.is_multiselect as boolean) ?? false; + const config = (args.config as object) ?? {}; + const path = `/objects/${objectSlug}/attributes`; + try { + const response = await api.post(path, { + data: { + title, + description, + api_slug, + type, + is_required, + is_unique, + is_multiselect, + config, + }, + }); + const attrId = response.data?.data?.id?.attribute_id ?? response.data?.id?.attribute_id; + return { + content: [ + { + type: "text", + text: attrId + ? `Attribute created: ${attrId}\n${JSON.stringify(response.data, null, 2)}` + : JSON.stringify(response.data, null, 2), + }, + ], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "POST", + getResponseData(error) + ); + } + } + + if (toolName === "update-attribute") { + const objectSlug = args.object as string; + const attribute = args.attribute as string; + const path = `/objects/${objectSlug}/attributes/${attribute}`; + const data: Record = {}; + if (args.title !== undefined) data.title = args.title; + if (args.description !== undefined) data.description = args.description; + if (args.api_slug !== undefined) data.api_slug = args.api_slug; + if (args.is_required !== undefined) data.is_required = args.is_required; + if (args.is_unique !== undefined) data.is_unique = args.is_unique; + if (args.is_archived !== undefined) data.is_archived = args.is_archived; + if (args.config !== undefined) data.config = args.config; + try { + const response = await api.patch(path, { data }); + return { + content: [{ type: "text", text: `Attribute updated.\n${JSON.stringify(response.data, null, 2)}` }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "PATCH", + getResponseData(error) + ); + } + } + + if (toolName === "list-tasks") { + const limit = (args.limit as number) ?? 20; + const offset = (args.offset as number) ?? 0; + const linked_object = args.linked_object as string | undefined; + const linked_record_id = args.linked_record_id as string | undefined; + const is_completed = args.is_completed as boolean | undefined; + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + if (linked_object) params.set("linked_object", linked_object); + if (linked_record_id) params.set("linked_record_id", linked_record_id); + if (typeof is_completed === "boolean") params.set("is_completed", String(is_completed)); + const path = `/tasks?${params.toString()}`; + try { + const response = await api.get(path); + const tasks = response.data.data || []; + return { + content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "create-task") { + const content = args.content as string; + const deadline_at = args.deadline_at as string | undefined; + const linked_records = args.linked_records as Array<{ target_object: string; target_record_id: string }> | undefined; + const path = "/tasks"; + try { + const body = { + data: { + content, + format: "plaintext", + deadline_at: deadline_at ?? null, + is_completed: false, + linked_records: linked_records?.length + ? linked_records.map((r) => ({ target_object: r.target_object, target_record_id: r.target_record_id })) + : [], + assignees: [], + }, + }; + const response = await api.post(path, body); + const taskId = response.data?.data?.id?.task_id ?? response.data?.id?.task_id; + return { + content: [ + { + type: "text", + text: taskId + ? `Created task: ${taskId}\n${JSON.stringify(response.data, null, 2)}` + : JSON.stringify(response.data, null, 2), + }, + ], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "POST", + getResponseData(error) + ); + } + } + + if (toolName === "get-task") { + const taskId = args.taskId as string; + const path = `/tasks/${taskId}`; + try { + const response = await api.get(path); + return { + content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "update-task") { + const taskId = args.taskId as string; + const is_completed = args.is_completed as boolean | undefined; + const deadline_at = args.deadline_at as string | undefined; + const content = args.content as string | undefined; + const path = `/tasks/${taskId}`; + try { + const data: Record = {}; + if (typeof is_completed === "boolean") data.is_completed = is_completed; + if (deadline_at !== undefined) data.deadline_at = deadline_at; + if (content !== undefined) data.content = content; + const response = await api.patch(path, { data }); + return { + content: [{ type: "text", text: `Updated task ${taskId}.\n${JSON.stringify(response.data, null, 2)}` }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "PATCH", + getResponseData(error) + ); + } + } + + if (toolName === "list-meetings") { + const limit = (args.limit as number) ?? 50; + const linked_object = args.linked_object as string | undefined; + const linked_record_id = args.linked_record_id as string | undefined; + const sort = args.sort as string | undefined; + const params = new URLSearchParams({ limit: String(limit) }); + if (linked_object) params.set("linked_object", linked_object); + if (linked_record_id) params.set("linked_record_id", linked_record_id); + if (sort) params.set("sort", sort); + const path = `/meetings?${params.toString()}`; + try { + const response = await api.get(path); + const meetings = response.data.data || []; + return { + content: [{ type: "text", text: JSON.stringify(meetings, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "get-meeting") { + const meetingId = args.meetingId as string; + const path = `/meetings/${meetingId}`; + try { + const response = await api.get(path); + return { + content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "list-call-recordings") { + const meetingId = args.meetingId as string; + const path = `/meetings/${meetingId}/call_recordings`; + try { + const response = await api.get(path); + const recordings = response.data.data || []; + return { + content: [{ type: "text", text: JSON.stringify(recordings, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "get-call-recording") { + const meetingId = args.meetingId as string; + const callRecordingId = args.callRecordingId as string; + const path = `/meetings/${meetingId}/call_recordings/${callRecordingId}`; + try { + const response = await api.get(path); + return { + content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "create-comment") { + const content = args.content as string; + const author_workspace_member_id = args.author_workspace_member_id as string; + const objectSlug = args.object as string; + const record_id = args.record_id as string; + const list = args.list as string | undefined; + const entry_id = args.entry_id as string | undefined; + const thread_id = args.thread_id as string | undefined; + const path = "/comments"; + try { + const data: Record = { + format: "plaintext", + content, + author: { type: "workspace-member", id: author_workspace_member_id }, + }; + if (thread_id) { + data.thread_id = thread_id; + } else if (list && entry_id) { + data.entry = { list, entry_id }; + } else if (objectSlug && record_id) { + data.record = { object: objectSlug, record_id }; + } else { + return { + content: [ + { + type: "text", + text: "Error: provide either (object + record_id), (list + entry_id), or thread_id.", + }, + ], + isError: true, + }; + } + const response = await api.post(path, { data }); + const commentId = response.data?.data?.id?.comment_id ?? response.data?.id?.comment_id; + return { + content: [ + { + type: "text", + text: commentId + ? `Comment created: ${commentId}\n${JSON.stringify(response.data, null, 2)}` + : JSON.stringify(response.data, null, 2), + }, + ], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "POST", + getResponseData(error) + ); + } + } + + if (toolName === "list-threads") { + const objectSlug = args.object as string | undefined; + const record_id = args.record_id as string | undefined; + const list = args.list as string | undefined; + const entry_id = args.entry_id as string | undefined; + const limit = (args.limit as number) ?? 10; + const offset = (args.offset as number) ?? 0; + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + if (objectSlug && record_id) { + params.set("object", objectSlug); + params.set("record_id", record_id); + } else if (list && entry_id) { + params.set("list", list); + params.set("entry_id", entry_id); + } else { + return { + content: [ + { + type: "text", + text: "Error: provide either (object + record_id) or (list + entry_id).", + }, + ], + isError: true, + }; + } + const path = `/threads?${params.toString()}`; + try { + const response = await api.get(path); + const threads = response.data.data || []; + return { + content: [{ type: "text", text: JSON.stringify(threads, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "get-comment") { + const commentId = args.commentId as string; + const path = `/comments/${commentId}`; + try { + const response = await api.get(path); + return { + content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "GET", + getResponseData(error) + ); + } + } + + if (toolName === "delete-comment") { + const commentId = args.commentId as string; + const path = `/comments/${commentId}`; + try { + await api.delete(path); + return { + content: [{ type: "text", text: `Comment ${commentId} deleted.` }], + isError: false, + }; + } catch (error) { + return createErrorResult( + error instanceof Error ? error : new Error("Unknown error"), + path, + "DELETE", + getResponseData(error) + ); + } + } + + throw new Error("Tool not found"); + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error executing tool '${toolName}': ${(error as Error).message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/index.ts b/src/index.ts index 33345f4..350cac6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,12 +9,18 @@ import { ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; +import { + getListToolsResponse, + handleListResources, + handleReadResource, + handleToolCall, +} from "./handlers.js"; // Configure Axios instance with Attio API credentials from environment const api = axios.create({ baseURL: "https://api.attio.com/v2", headers: { - "Authorization": `Bearer ${process.env.ATTIO_API_KEY}`, + Authorization: `Bearer ${process.env.ATTIO_API_KEY}`, "Content-Type": "application/json", }, }); @@ -32,310 +38,19 @@ const server = new Server( }, ); -// Helper function to create detailed error responses -function createErrorResult(error: Error, url: string, method: string, responseData: any) { - return { - content: [ - { - type: "text", - text: `ERROR: ${error.message}\n\n` + - `=== Request Details ===\n` + - `- Method: ${method}\n` + - `- URL: ${url}\n\n` + - `=== Response Details ===\n` + - `- Status: ${responseData.status}\n` + - `- Headers: ${JSON.stringify(responseData.headers || {}, null, 2)}\n` + - `- Data: ${JSON.stringify(responseData.data || {}, null, 2)}\n` - }, - ], - isError: true, - error: { - code: responseData.status || 500, - message: error.message, - details: responseData.data?.error || "Unknown error occurred" - } - }; -} - -// Example: List Resources Handler (List Companies) -server.setRequestHandler(ListResourcesRequestSchema, async (request) => { - const path = "/objects/companies/records/query"; - try { - const response = await api.post(path, { - limit: 20, - sorts: [{ attribute: 'last_interaction', field: 'interacted_at', direction: 'desc' }] - }); - const companies = response.data.data || []; - - return { - resources: companies.map((company: any) => ({ - uri: `attio://companies/${company.id?.record_id}`, - name: company.values?.name?.[0]?.value || "Unknown Company", - mimeType: "application/json", - })), - description: `Found ${companies.length} companies that you have interacted with most recently`, - }; - } catch (error) { - return createErrorResult( - error instanceof Error ? error : new Error("Unknown error"), - path, - "POST", - (error as any).response?.data || {} - ); - } -}); - -// Example: Read Resource Handler (Get Company Details) -server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const companyId = request.params.uri.replace("attio://companies/", ""); - try { - const path = `/objects/companies/records/${companyId}`; - const response = await api.get(path); - - return { - contents: [ - { - uri: request.params.uri, - text: JSON.stringify(response.data, null, 2), - mimeType: "application/json", - }, - ], - }; - } catch (error) { - return createErrorResult( - error instanceof Error ? error : new Error("Unknown error"), - `/objects/companies/${companyId}`, - "GET", - (error as any).response?.data || {} - ); - } -}); - -// Example: List Tools Handler -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: "search-companies", - description: "Search for companies by name", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "Company name or keyword to search for", - }, - }, - required: ["query"], - }, - }, - { - name: "read-company-details", - description: "Read details of a company", - inputSchema: { - type: "object", - properties: { - uri: { - type: "string", - description: "URI of the company to read", - }, - }, - required: ["uri"], - }, - }, - { - name: "read-company-notes", - description: "Read notes for a company", - inputSchema: { - type: "object", - properties: { - uri: { - type: "string", - description: "URI of the company to read notes for", - }, - limit: { - type: "number", - description: "Maximum number of notes to fetch (optional, default 10)", - }, - offset: { - type: "number", - description: "Number of notes to skip (optional, default 0)", - }, - }, - required: ["uri"], - }, - }, - { - name: "create-company-note", - description: "Add a new note to a company", - inputSchema: { - type: "object", - properties: { - companyId: { - type: "string", - description: "ID of the company to add the note to", - }, - noteTitle: { - type: "string", - description: "Title of the note", - }, - noteText: { - type: "string", - description: "Text content of the note", - }, - }, - required: ["companyId", "noteTitle", "noteText"], - }, - }, - ], - }; -}); - -// Example: Call Tool Handler with enhanced error handling -server.setRequestHandler(CallToolRequestSchema, async (request) => { - const toolName = request.params.name; - try { - - if (toolName === "search-companies") { - const query = request.params.arguments?.query as string; - const path = "/objects/companies/records/query"; - try { - const response = await api.post(path, { - filter: { - name: { "$contains": query }, - } - }); - const results = response.data.data || []; - - const companies = results.map((company: any) => { - const companyName = company.values?.name?.[0]?.value || "Unknown Company"; - const companyId = company.id?.record_id || "Record ID not found"; - return `${companyName}: attio://companies/${companyId}`; - }) - .join("\n"); - return { - content: [ - { - type: "text", - text: `Found ${results.length} companies:\n${companies}`, - }, - ], - isError: false, - }; - } catch (error) { - return createErrorResult( - error instanceof Error ? error : new Error("Unknown error"), - path, - "GET", - (error as any).response?.data || {} - ); - } - } - - if (toolName === "read-company-details") { - const uri = request.params.arguments?.uri as string; - const companyId = uri.replace("attio://companies/", ""); - const path = `/objects/companies/records/${companyId}`; - try { - const response = await api.get(path); - return { - content: [ - { - type: "text", - text: `Company details for ${companyId}:\n${JSON.stringify(response.data, null, 2)}`, - }, - ], - isError: false, - }; - } catch (error) { - return createErrorResult( - error instanceof Error ? error : new Error("Unknown error"), - path, - "GET", - (error as any).response?.data || {} - ); - } - } - - if (toolName == 'read-company-notes') { - const uri = request.params.arguments?.uri as string; - const limit = request.params.arguments?.limit as number || 10; - const offset = request.params.arguments?.offset as number || 0; - const companyId = uri.replace("attio://companies/", ""); - const path = `/notes?limit=${limit}&offset=${offset}&parent_object=companies&parent_record_id=${companyId}`; - - try { - const response = await api.get(path); - const notes = response.data.data || []; - - return { - content: [ - { - type: "text", - text: `Found ${notes.length} notes for company ${companyId}:\n${notes.map((note: any) => JSON.stringify(note)).join("----------\n")}`, - }, - ], - isError: false, - }; - } catch (error) { - return createErrorResult( - error instanceof Error ? error : new Error("Unknown error"), - path, - "GET", - (error as any).response?.data || {} - ); - } - } - - if (toolName === "create-company-note") { - const companyId = request.params.arguments?.companyId as string; - const noteTitle = request.params.arguments?.noteTitle as string; - const noteText = request.params.arguments?.noteText as string; - const url = `notes`; +server.setRequestHandler(ListResourcesRequestSchema, (request) => + handleListResources(api, request as { params?: { uri?: string } }) +); - try { - const response = await api.post(url, { - data: { - format: "plaintext", - parent_object: "companies", - parent_record_id: companyId, - title: `[AI] ${noteTitle}`, - content: noteText - }, - }); +server.setRequestHandler(ReadResourceRequestSchema, (request) => + handleReadResource(api, request as { params?: { uri?: string } }) +); - return { - content: [ - { - type: "text", - text: `Note added to company ${companyId}: attio://notes/${response.data?.id?.note_id}`, - }, - ], - isError: false, - }; - } catch (error) { - return createErrorResult( - error instanceof Error ? error : new Error("Unknown error"), - url, - "POST", - (error as any).response?.data || {} - ); - } - } +server.setRequestHandler(ListToolsRequestSchema, () => getListToolsResponse()); - throw new Error("Tool not found"); - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error executing tool '${toolName}': ${(error as Error).message}`, - }, - ], - isError: true, - }; - } -}); +server.setRequestHandler(CallToolRequestSchema, (request) => + handleToolCall(api, request) +); // Main function async function main() { diff --git a/src/tool-definitions.ts b/src/tool-definitions.ts new file mode 100644 index 0000000..3972ae2 --- /dev/null +++ b/src/tool-definitions.ts @@ -0,0 +1,31 @@ +/** + * MCP tool definitions returned by ListTools. + */ +export const TOOLS = [ + { name: "search-companies", description: "Search for companies by name", inputSchema: { type: "object", properties: { query: { type: "string", description: "Company name or keyword to search for" } }, required: ["query"] } }, + { name: "read-company-details", description: "Read details of a company", inputSchema: { type: "object", properties: { uri: { type: "string", description: "URI of the company to read" } }, required: ["uri"] } }, + { name: "read-company-notes", description: "Read notes for a company", inputSchema: { type: "object", properties: { uri: { type: "string", description: "URI of the company to read notes for" }, limit: { type: "number", description: "Maximum number of notes to fetch (optional, default 10)" }, offset: { type: "number", description: "Number of notes to skip (optional, default 0)" } }, required: ["uri"] } }, + { name: "create-company-note", description: "Add a new note to a company", inputSchema: { type: "object", properties: { companyId: { type: "string", description: "ID of the company to add the note to" }, noteTitle: { type: "string", description: "Title of the note" }, noteText: { type: "string", description: "Text content of the note" } }, required: ["companyId", "noteTitle", "noteText"] } }, + { name: "list-objects", description: "List all objects in the workspace (e.g. people, companies, deals, custom objects)", inputSchema: { type: "object", properties: {} } }, + { name: "list-attributes", description: "List attributes for an object (schema/fields for that object)", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug or ID (e.g. people, companies, deals)" } }, required: ["object"] } }, + { name: "query-records", description: "Query records for any object with optional filter and sort", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug (e.g. people, companies, deals)" }, limit: { type: "number", description: "Max results (default 20)", default: 20 }, offset: { type: "number", description: "Skip N results (default 0)", default: 0 }, filter: { type: "object", description: "Attio filter object (optional)" }, sorts: { type: "array", description: "Sort spec(s) e.g. [{ attribute: 'name', direction: 'asc' }] (optional)" } }, required: ["object"] } }, + { name: "get-record", description: "Get a single record by object and record ID", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug (e.g. people, companies)" }, recordId: { type: "string", description: "Record UUID" } }, required: ["object", "recordId"] } }, + { name: "create-record", description: "Create a new record for any object. Values keyed by attribute api_slug. Accepts shorthand (e.g. name: 'Acme') or Attio shape (name: [{ value: 'Acme' }]). Text/number/checkbox: use 'value'. Email: { email_address }. Select/status: { status_id }. Record ref: { target_object, target_record_id }. Do not include attribute_type in values.", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug (e.g. people, companies, deals)" }, values: { type: "object", description: "Attribute values: key = attribute api_slug, value = primitive (wrapped as [{ value }]) or array of value objects e.g. [{ value: '...' }], [{ email_address: '...' }], [{ status_id: 'uuid' }]" } }, required: ["object", "values"] } }, + { name: "update-record", description: "Update an existing record (PATCH). Values must be Attio shape: each attribute = array of value objects. Accepts shorthand (e.g. description: 'New text') or full form (description: [{ value: 'New text' }]). Text: [{ value: '...' }]. Email: [{ email_address: '...' }]. Select/status: [{ status_id: 'uuid' }]. Do not include attribute_type—causes 400.", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug" }, recordId: { type: "string", description: "Record UUID" }, values: { type: "object", description: "Attribute values (partial update). Key = attribute api_slug, value = string/number/boolean (auto-wrapped) or array of value objects, e.g. { description: [{ value: '...' }] }" } }, required: ["object", "recordId", "values"] } }, + { name: "delete-record", description: "Delete a record by object and record ID. This is destructive and cannot be undone.", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug (e.g. people, companies)" }, recordId: { type: "string", description: "Record UUID" } }, required: ["object", "recordId"] } }, + { name: "get-object", description: "Get a single object by slug or UUID (schema/metadata for an object type)", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug or UUID" } }, required: ["object"] } }, + { name: "create-attribute", description: "Create a new attribute on an object. Requires object_configuration:read-write scope.", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug or UUID" }, title: { type: "string", description: "Display name of the attribute" }, api_slug: { type: "string", description: "Unique snake_case slug for API/URLs" }, type: { type: "string", description: "Attribute type", enum: ["text", "number", "checkbox", "currency", "date", "timestamp", "rating", "status", "select", "record-reference", "actor-reference", "location", "domain", "email-address", "phone-number"] }, description: { type: "string", description: "Optional description" }, is_required: { type: "boolean", description: "Whether new records must have a value", default: false }, is_unique: { type: "boolean", description: "Whether values must be unique", default: false }, is_multiselect: { type: "boolean", description: "Whether multiple values allowed", default: false }, config: { type: "object", description: "Type-specific config (e.g. currency, record_reference.allowed_objects)" } }, required: ["object", "title", "api_slug", "type"] } }, + { name: "update-attribute", description: "Update an existing attribute on an object. Requires object_configuration:read-write scope.", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug or UUID" }, attribute: { type: "string", description: "Attribute slug or UUID" }, title: { type: "string", description: "New display name" }, description: { type: "string", description: "New description" }, api_slug: { type: "string", description: "New API slug" }, is_required: { type: "boolean", description: "Whether required" }, is_unique: { type: "boolean", description: "Whether unique" }, is_archived: { type: "boolean", description: "Whether to archive the attribute" }, config: { type: "object", description: "Type-specific config" } }, required: ["object", "attribute"] } }, + { name: "list-tasks", description: "List tasks. Optionally filter by linked record, assignee, or completion", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Max results (default 20)", default: 20 }, offset: { type: "number", description: "Skip N results (default 0)", default: 0 }, linked_object: { type: "string", description: "Filter by linked object slug (e.g. people, companies)" }, linked_record_id: { type: "string", description: "Filter by linked record ID (use with linked_object)" }, is_completed: { type: "boolean", description: "Filter by completion (true/false)" } } } }, + { name: "create-task", description: "Create a new task with optional deadline and linked records", inputSchema: { type: "object", properties: { content: { type: "string", description: "Task content (plaintext)" }, deadline_at: { type: "string", description: "Optional ISO 8601 deadline" }, linked_records: { type: "array", description: "Optional: [{ target_object: 'people'|'companies', target_record_id: 'uuid' }]" } }, required: ["content"] } }, + { name: "get-task", description: "Get a single task by ID", inputSchema: { type: "object", properties: { taskId: { type: "string", description: "Task UUID" } }, required: ["taskId"] } }, + { name: "update-task", description: "Update a task (e.g. mark complete, change deadline)", inputSchema: { type: "object", properties: { taskId: { type: "string", description: "Task UUID" }, is_completed: { type: "boolean", description: "Set completion status" }, deadline_at: { type: "string", description: "New deadline (ISO 8601)" }, content: { type: "string", description: "New task content" } }, required: ["taskId"] } }, + { name: "list-meetings", description: "List meetings with optional filters (linked record, date range)", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Max results (1-200, default 50)", default: 50 }, linked_object: { type: "string", description: "Filter by linked object slug" }, linked_record_id: { type: "string", description: "Filter by linked record ID" }, sort: { type: "string", description: "start_asc or start_desc", enum: ["start_asc", "start_desc"] } } } }, + { name: "get-meeting", description: "Get a single meeting by ID", inputSchema: { type: "object", properties: { meetingId: { type: "string", description: "Meeting UUID" } }, required: ["meetingId"] } }, + { name: "list-call-recordings", description: "List call recordings for a meeting", inputSchema: { type: "object", properties: { meetingId: { type: "string", description: "Meeting UUID" } }, required: ["meetingId"] } }, + { name: "get-call-recording", description: "Get a single call recording (includes transcript/speaker info when available)", inputSchema: { type: "object", properties: { meetingId: { type: "string", description: "Meeting UUID" }, callRecordingId: { type: "string", description: "Call recording UUID" } }, required: ["meetingId", "callRecordingId"] } }, + { name: "create-comment", description: "Create a comment on a record, list entry, or as a reply to a thread. Requires author workspace member UUID. Provide either (object + record_id), (list + entry_id), or thread_id.", inputSchema: { type: "object", properties: { content: { type: "string", description: "Comment text (plaintext)" }, author_workspace_member_id: { type: "string", description: "Workspace member UUID (who is posting)" }, object: { type: "string", description: "Object slug for record (use with record_id)" }, record_id: { type: "string", description: "Record UUID (use with object)" }, list: { type: "string", description: "List slug or ID for entry (use with entry_id)" }, entry_id: { type: "string", description: "Entry UUID (use with list)" }, thread_id: { type: "string", description: "Thread UUID to reply to (omit for new top-level comment)" } }, required: ["content", "author_workspace_member_id"] } }, + { name: "list-threads", description: "List comment threads on a record or list entry", inputSchema: { type: "object", properties: { object: { type: "string", description: "Object slug (e.g. companies, people). Use with record_id." }, record_id: { type: "string", description: "Record UUID. Use with object." }, list: { type: "string", description: "List slug or ID. Use with entry_id." }, entry_id: { type: "string", description: "Entry UUID. Use with list." }, limit: { type: "number", description: "Max results (default 10, max 50)", default: 10 }, offset: { type: "number", description: "Skip N results", default: 0 } } } }, + { name: "get-comment", description: "Get a single comment by ID", inputSchema: { type: "object", properties: { commentId: { type: "string", description: "Comment UUID" } }, required: ["commentId"] } }, + { name: "delete-comment", description: "Delete a comment by ID. If the comment is at the head of a thread, all messages in the thread are also deleted.", inputSchema: { type: "object", properties: { commentId: { type: "string", description: "Comment UUID" } }, required: ["commentId"] } }, +];