Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
push:
pull_request:

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.34.9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: dtolnay/rust-toolchain@stable
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm build
- run: cargo test --manifest-path src-tauri/core/Cargo.toml
42 changes: 34 additions & 8 deletions mcp/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# FastTask MCP サーバ

FastTask が起動時に立てるローカル HTTP API (127.0.0.1 のみ) を叩く、依存ゼロの MCP stdio ブリッジ。
FastTask の画面と同じ操作を、MCP の tools として出す stdio サーバ。
公式 SDK (`@modelcontextprotocol/sdk`) を使い、initialize、tools/list、tools/call に対応する。

必要条件: Node 18+ / FastTask アプリが起動していること。
必要条件: Node 22+。操作先の FastTask アプリが起動していること。

## 仕組み

Expand All @@ -12,7 +13,7 @@ FastTask は起動時に `app_data_dir/api.json` へ実ポートと認証トー
- Linux: `~/.local/share/com.fasttask.desktop/api.json`
- Windows: `%APPDATA%\com.fasttask.desktop\api.json`

MCP サーバはこのファイルを毎リクエスト読み直すので、設定は不要。環境変数
MCP サーバはこのファイルを毎リクエスト読み直す。環境変数
`FASTTASK_PORT` / `FASTTASK_TOKEN` / `FASTTASK_APP_DATA_DIR` で上書きできる。

## 登録
Expand All @@ -36,18 +37,42 @@ Claude Desktop (`claude_desktop_config.json`) / 手動設定:
}
```

リポジトリの依存を入れてから起動する。

```sh
pnpm install
node mcp/server.js
```

## tools

| tool | 引数 |
| tool | 内容 |
| --- | --- |
| `list_tasks` | `status?` (`todo` \| `done`) |
| `create_task` | `title` (必須), `dueDate?`, `url?`, `tags?` |
| `update_task` | `id` (必須), `title?`, `status?`, `dueDate?`, `url?`, `tags?` |
| `delete_task` | `id` (必須) — 子タスクも一緒に削除 |
| `search_tasks` | キーワード、状態、プロジェクト、タグ、期限、MUST、確認待ちで探す |
| `get_task` | 1件取得 |
| `add_tasks` | 入力欄と同じ文、またはフィールド指定で追加 |
| `edit_task` | タイトル、プロジェクト、期限、URL、タグ、MUST、確認待ち、親 |
| `set_task_status` | 完了 / 未完了。子が残っている親は cascade が必要 |
| `delete_tasks` | 削除(子孫も消える) |
| `reorder_tasks` | 未完了の並び替え |
| `reorder_projects` | プロジェクト見出しの並び替え |
| `list_tags` / `save_tag` / `delete_tag` | タグマスタ |
| `get_settings` / `update_settings` | 表示設定とフォーカスの分数 |
| `copy_list` | 画面のコピーと同じ Markdown |
| `daily_result` | リザルト集計 |
| `record_focus` | フォーカス時間を記録 |
| `clear_all_data` | タスクとタグを全削除(`confirm: "delete"`) |
| `google_sync` | 連携の状態、同期スイッチ、今すぐ同期、タスクリスト一覧 |

クライアントシークレットとブラウザでの Google 接続はアプリの設定画面だけで行う。

## HTTP API を直接叩く

全リクエストに `Authorization: Bearer <token>` が必須 (無いと 401)。
既存の `GET/POST /tasks`、`PATCH/DELETE /tasks/:id` はそのまま使える。
追加分は `POST /tasks/batch`、`POST /tasks/status`、`POST /tasks/delete`、
`POST /tasks/reorder`、`POST /projects/reorder`、`/tags`、`/settings`、
`GET /export`、`GET /result`、`POST /focus`、`POST /data/clear`、`/google`。

```sh
BASE=http://127.0.0.1:$(sed -n 's/.*"port": *\([0-9]*\).*/\1/p' ~/Library/Application\ Support/com.fasttask.desktop/api.json)
Expand All @@ -68,3 +93,4 @@ sh mcp/smoke.sh
```

トークン無し → 401、作成 → 一覧に出る、を確認して後片付けする。
プロトコルの確認は `pnpm test` の中の `mcp/protocol.test.js` が行う。
80 changes: 80 additions & 0 deletions mcp/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

export function appDataDir() {
if (process.env.FASTTASK_APP_DATA_DIR) return process.env.FASTTASK_APP_DATA_DIR;
if (process.platform === "darwin") {
return join(homedir(), "Library", "Application Support", "com.fasttask.desktop");
}
if (process.platform === "win32") {
return join(process.env.APPDATA || "", "com.fasttask.desktop");
}
return join(homedir(), ".local", "share", "com.fasttask.desktop");
}

export function config() {
let port = process.env.FASTTASK_PORT;
let token = process.env.FASTTASK_TOKEN;
if (!port || !token) {
const file = JSON.parse(readFileSync(join(appDataDir(), "api.json"), "utf8"));
port ||= String(file.port ?? "");
token ||= file.token;
}
if (!port || !token) {
throw new Error("FastTask の接続先が分かりません。アプリを起動してください。");
}
return { base: `http://127.0.0.1:${port}`, token };
}

export async function api(method, path, body) {
const { base, token } = config();
let response;
try {
response = await fetch(base + path, {
method,
headers: {
Authorization: `Bearer ${token}`,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`FastTask に接続できません。アプリが起動しているか確認してください (${message})`);
}
const text = await response.text();
let parsed = null;
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = { error: text };
}
}
if (!response.ok) {
const message = parsed && typeof parsed === "object" && parsed.error
? parsed.error
: `HTTP ${response.status}`;
throw new Error(String(message));
}
return parsed;
}

export function textResult(data) {
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
}

export async function call(method, path, body) {
try {
return textResult(await api(method, path, body));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { isError: true, content: [{ type: "text", text: message }] };
}
}

export function defined(fields) {
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
}
220 changes: 220 additions & 0 deletions mcp/protocol.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const bin = path.join(root, "src-tauri/core/target/debug/fasttask-api");
const token = "test-token";

let dir;
let apiProc;
let client;

function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}

function waitFor(stream, needle) {
return new Promise((resolve, reject) => {
let buf = "";
const timer = setTimeout(() => reject(new Error(`timed out waiting for ${needle}`)), 8000);
stream.on("data", (chunk) => {
buf += chunk.toString();
if (buf.includes(needle)) {
clearTimeout(timer);
resolve();
}
});
});
}

function textOf(result) {
const block = result.content?.find((item) => item.type === "text");
return block?.text ?? "";
}

async function tool(name, args = {}) {
return client.callTool({ name, arguments: args });
}

test.before(async () => {
execFileSync(
"cargo",
["+stable", "build", "--manifest-path", "src-tauri/core/Cargo.toml", "--bin", "fasttask-api"],
{ cwd: root, stdio: "inherit" },
);
dir = await mkdtemp(path.join(tmpdir(), "fasttask-mcp-"));
const port = await freePort();
apiProc = spawn(bin, [], {
env: {
...process.env,
FASTTASK_DIR: dir,
FASTTASK_PORT: String(port),
FASTTASK_TOKEN: token,
},
stdio: ["ignore", "ignore", "pipe"],
});
await waitFor(apiProc.stderr, "listening");
client = new Client({ name: "fasttask-protocol-test", version: "0.0.0" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(root, "mcp/server.js")],
env: {
...process.env,
FASTTASK_PORT: String(port),
FASTTASK_TOKEN: token,
},
stderr: "pipe",
});
await client.connect(transport);
});

test.after(async () => {
await client?.close().catch(() => {});
apiProc?.kill();
if (dir) await rm(dir, { recursive: true, force: true });
});

let gate = Promise.resolve();
function testSerial(name, fn) {
test(name, async () => {
const turn = gate;
let release;
gate = new Promise((resolve) => {
release = resolve;
});
await turn;
try {
await fn();
} finally {
release();
}
});
}

testSerial("initialize exposes tools and answers ping", async () => {
const capabilities = client.getServerCapabilities();
assert.ok(capabilities?.tools);
assert.match(client.getInstructions() ?? "", /search_tasks/);
assert.equal(client.getServerVersion()?.name, "fasttask");
const pong = await client.ping();
assert.deepEqual(pong, {});
});

testSerial("tools/list has schemas an agent can fill in", async () => {
const listed = await client.listTools();
const names = listed.tools.map((tool) => tool.name);
for (const name of [
"search_tasks",
"add_tasks",
"edit_task",
"set_task_status",
"delete_tasks",
"reorder_tasks",
"reorder_projects",
"save_tag",
"copy_list",
"daily_result",
"google_sync",
]) {
assert.ok(names.includes(name), name);
}
for (const tool of listed.tools) {
assert.equal(typeof tool.description, "string");
assert.ok(tool.description.length > 10);
assert.equal(tool.inputSchema.type, "object");
}
});

testSerial("tools/call creates, edits, completes, and deletes like the app", async () => {
const added = await tool("add_tasks", { text: ":work 資料 tomorrow, レビュー" });
assert.notEqual(added.isError, true, textOf(added));
const created = JSON.parse(textOf(added)).tasks;
assert.equal(created.length, 2);
assert.equal(created[0].projectName, "work");
assert.equal(created[1].projectName, "work");
const parent = created[0].id;
const child = created[1].id;

const found = JSON.parse(textOf(await tool("search_tasks", { query: "レビュー", project: "work", status: "todo" })));
assert.equal(found.count, 1);

const nested = await tool("edit_task", { id: child, parentId: parent });
assert.notEqual(nested.isError, true, textOf(nested));
assert.equal(JSON.parse(textOf(nested)).parentId, parent);

const blocked = await tool("set_task_status", { id: parent, status: "done" });
assert.equal(blocked.isError, true);
assert.match(textOf(blocked), /children pending/);

const done = await tool("set_task_status", { id: parent, status: "done", cascade: true });
assert.notEqual(done.isError, true, textOf(done));

const markdown = textOf(await tool("copy_list", { target: "todo" }));
assert.match(markdown, /未完了タスク|Todo Tasks/);

const result = JSON.parse(textOf(await tool("daily_result")));
assert.equal(typeof result.completedCount, "number");
assert.ok(result.shareText);

const removed = await tool("delete_tasks", { id: parent });
assert.notEqual(removed.isError, true, textOf(removed));
const also = JSON.parse(textOf(removed)).alsoDeleted;
assert.ok(also.includes(child));
});

testSerial("settings, focus, tags, and google status stay free of secrets", async () => {
const settings = await tool("update_settings", { theme: "dark", language: "ja", lastFocusMinutes: 30 });
assert.notEqual(settings.isError, true, textOf(settings));
assert.equal(JSON.parse(textOf(await tool("get_settings"))).theme, "dark");

const focus = JSON.parse(textOf(await tool("record_focus", { minutes: 12 })));
assert.ok(focus.focusMinutes >= 12);

const tag = JSON.parse(textOf(await tool("save_tag", { name: "開発", keywords: ["資料"] })));
assert.equal(tag.name, "開発");
const tags = JSON.parse(textOf(await tool("list_tags")));
assert.ok(tags.tags.some((item) => item.id === tag.id));

await writeFile(
path.join(dir, "google.json"),
JSON.stringify({
client_secret: "super-secret-value",
refresh_token: "refresh-value",
client_id: "client",
email: "a@b.c",
auto_push: false,
}),
);
const status = await tool("google_sync", { action: "status" });
assert.notEqual(status.isError, true, textOf(status));
const body = textOf(status);
assert.doesNotMatch(body, /super-secret-value/);
assert.doesNotMatch(body, /refresh-value/);
assert.equal(JSON.parse(body).connected, true);
const refused = await tool("google_sync", { action: "update", clientId: "client" });
assert.notEqual(refused.isError, true, textOf(refused));
});

testSerial("unknown tool and invalid arguments come back as tool errors", async () => {
const missing = await tool("not_a_tool", {});
assert.equal(missing.isError, true);
const invalid = await tool("add_tasks", {});
assert.equal(invalid.isError, true);
assert.match(textOf(invalid), /text|tasks/);
});
Loading
Loading