最初の投稿では、118個のMCPツールに対してAIが実際に何をするのかを紹介しました。 2つ目では、それらをどのように整理したかを説明しました。どちらの投稿も、動作する接続が前提でした。今回は、その最も難しい部分――その接続を安全に動作させること――を扱います。
ほとんどのMCPサーバーはローカルで動作します。Claudeの設定にJSONブロックを追加して、APIキーを貼り付ければ完了です。これは開発者向けツールなら機能します。しかし、ユーザーにチームやロール、そして他の人と共有するデータがあるSaaSプロダクトではうまくいきません。
私たちには別の仕組みが必要でした。ユーザーがブラウザからログインし、チームを選び、AIに何をアクセスさせるかを指定し、必要なときはいつでも取り消せる、リモートのMCPサーバーです。APIキーは使いません。ディスク上の設定ファイルもありません。
ローカルMCPサーバーの問題点
標準的なMCPのセットアップは次のようになります:
{
"mcpServers": {
"my-tool": {
"command": "npx",
"args": ["my-mcp-server"],
"env": {
"API_KEY": "sk-live-abc123"
}
}
}
}
これにはSaaSプロダクトにとって3つの問題があります:
1. APIキーは静的なシークレットです。キーが漏洩した場合、誰かが手動でローテーションするまで完全にアクセスされ続けます。期限切れも、スコープもなく、新しいキーを発行しない限り取り消し(リボーク)できません。
2. ユーザーの同意フローがありません。ユーザーはキーを貼り付けて「うまくいくことを願う」だけです。「このAIアシスタントが請求書を読み取り、取引を作成しようとしています――承認しますか?」という画面がありません。
3. マルチテナンシーに対応できません。プロダクトにチームがある場合、そのAPIキーはどのチームに属しますか?ユーザーが3つのチームに所属している場合、3つのキーが必要ですか?どうやって切り替えるのでしょうか?
OAuthを備えたリモートMCPサーバーなら、これら3つすべてを解決できます。ユーザーはブラウザで認証し、チームを選び、特定の権限を付与し、1時間で期限切れになるトークンを受け取ります。AIはパスワードを一切見ません。ユーザーはアプリの設定ページから切断できます。
私たちが作ったもの
私たちのMCPサーバーは https://mcp.paperlink.online/api/mcp/mcp で動作し、streamable HTTPトランスポートを使います――ローカルのプロセスなし、stdioなし、インストールするバイナリもありません。接続コマンドは次のとおりです:
claude mcp add --transport http paperlink https://mcp.paperlink.online/api/mcp/mcp
クライアントが初めて接続するとき、2つのwell-knownドキュメントを通じてOAuthのエンドポイントを検出します。その後、PKCEを使った標準的なOAuth 2.1の認可コードフローを実行します。ユーザーはブラウザ上で同意画面を見ます。承認後、クライアントは短命なアクセストークンと、30日間有効なリフレッシュトークンを受け取ります。
全体のフローは次のとおりです:
Client Server User's Browser
| | |
|-- GET /.well-known/ | |
| oauth-protected-resource ->| |
|<- {auth_server, scopes} | |
| | |
|-- GET /.well-known/ | |
| oauth-authorization-server | |
|<- {authorize, token, revoke} | |
| | |
|-- Open browser: /authorize? | |
| code_challenge=...& | |
| scope=invoices:read ------>|-- Redirect to consent UI ------->|
| | |
| | User picks team, |
| | reviews scopes, |
| | clicks Approve |
| | |
|<- POST consent (teamId, scopes) -|
|<- Redirect: ?code=abc123 -| |
| | |
|-- POST /token -| |
| code=abc123& | |
| code_verifier=xyz | |
|<- {access_token, refresh} | |
| | |
|-- POST /api/mcp/mcp -| |
| Authorization: Bearer ... | |
|<- Tool results | |
では、各パーツを順に説明します。
Step 1: Discovery
MCPクライアントは、2つの標準的なドキュメントを通じて認証エンドポイントを検出します。保護されたリソースのメタデータは、クライアントにどこで認証するかを伝えます:
GET https://mcp.paperlink.online/.well-known/oauth-protected-resource
{
"resource": "https://mcp.paperlink.online/api/mcp/mcp",
"authorization_servers": ["https://app.paperlink.online"],
"scopes_supported": [
"invoices:read", "accounting:read", "accounting:write", "accounting:delete",
"companies:read", "companies:write", "clients:read", "clients:write",
"products:read", "products:write", "companies:delete", "clients:delete",
"products:delete", "invoices:write", "invoices:delete",
"estimates:read", "estimates:write", "estimates:delete",
"teams:read", "teams:write", "billing:read",
"ai:read", "ai:write", "sharing:read", "sharing:write",
]
}
そして、認可サーバのメタデータがクライアントに正確なエンドポイントを伝えます。
GET https://app.paperlink.online/.well-known/oauth-authorization-server
{
"issuer": "https://app.paperlink.online",
"authorization_endpoint": "https://app.paperlink.online/api/oauth/authorize",
"token_endpoint": "https://app.paperlink.online/api/oauth/token",
"revocation_endpoint": "https://app.paperlink.online/api/oauth/revoke",
"registration_endpoint": "https://app.paperlink.online/api/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["invoices:read", "..."]
}
code_challenge_methods_supported: ["S256"] に注目してください。私たちはプレーン(plain)をサポートせず、S256 のみをサポートします。これは意図的です。プレーンの PKCE は、PKCE がまったくない場合と比べて、ほとんどセキュリティ上の利点がありません。
また token_endpoint_auth_methods_supported: ["none"] にも注目してください。MCP クライアントはパブリッククライアント(クライアントシークレットなし)です。そのため認証は PKCE を通じて完全に行われます。
Step 2: Authorization
クライアントはユーザーのブラウザを認可エンドポイントに開きます。
GET /api/oauth/authorize?
response_type=code&
client_id=claude-desktop&
redirect_uri=http://localhost:5555/callback&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256&
scope=invoices:read+accounting:write+sharing:read&
state=random-csrf-token
サーバはリクエストを検証し、同意(コンセント)UIへリダイレクトします。ユーザーが何かを見る前に、次の2点が検証されます。
// S256 のみ - プレーンの PKCE を拒否する
if (codeChallengeMethod !== 'S256') {
return NextResponse.json({
error:'invalid_request',
error_description:'Only code_challenge_method=S256 is supported',
}, { status: 400 });
}
返却形式: {"translated": "翻訳されたHTML"}// リダイレクト URI は既知のパターンに一致していなければなりません
if (!isAllowedRedirectUri(clientId, redirectUri)) {
return NextResponse.json({
error: 'invalid_request',
error_description: 'redirect_uri はこのクライアントに登録されていません',
}, { status: 400 });
}
リダイレクト URI のバリデーションは、もう少し掘り下げる価値があります。MCP クライアントは localhost のコールバックを使います(Claude Desktop は http://localhost:PORT/callback を使用し、ブラウザベースのクライアントである Claude.ai や ChatGPT はそれぞれ独自のドメインを使用します)。私たちは allowlist を維持しています:
const SAFE_REDIRECT_PATTERNS = [
'http://127.0.0.1:*/*' , // デスクトップクライアント(IP)
'http://localhost:*/*', // デスクトップクライアント(ホスト名)
'https://*.claude.ai/*', // Claude.ai
'https://claude.ai/*',
'https://*.chatgpt.com/*', // ChatGPT
'https://chatgpt.com/*',
'https://*.openai.com/*', // OpenAI
'https://*.perplexity.ai/*', // Perplexity
'https://*.mistral.ai/*', // Mistral
'https://*.vscode.dev/*', // VS Code
// ... mcpClientRegistry.ts の完全な一覧
];
パターンはグロブ(glob)ライクなマッチングを使います。* がポート位置にある場合は任意のポートに一致し、*.claude.ai は任意のサブドメインに一致します。マッチャーは URI を解析し、スキーム、ホスト、ポート、パスをそれぞれ別々に比較します。
client_id は、リダイレクト URI が既知のパターンに一致する限り通過します。これは動的クライアント登録(dynamic client registration)です。事前にクライアントを登録しません。セキュリティはクライアントシークレットではなく、PKCE に加えてリダイレクト URI の検証から生まれます。
Step 3: 同意画面
同意画面は通常の Next.js ページです。ユーザーはすでにログインしている必要があります(NextAuth セッションが必要)。ユーザーには次の2つが表示されます:チームピッカーとスコープの一覧。
export function OAuthConsentForm({
teams,
requestedScopes,
clientId,
redirectUri,
state,
codeChallenge,
codeChallengeMethod,
dict,
}: OAuthConsentFormProps) {
const [selectedTeamId, setSelectedTeamId] = useState<string | undefined>(
teams[0]?.id
);
return (
<form action={formAction}>
{/* チーム選択 - 接続1つ = チーム1つ */}
<Dropdown
options={teams.map(t => ({ value: t.id, label: t.name }))}
value={selectedTeamId}
onChange={setSelectedTeamId}
label={dict.consent.selectTeam}
/>
{/* スコープ表示 - ユーザーは許可する内容を確認できます */}
<ul>
{requestedScopes.map(scope => (
<li key={scope}>
<span className="text-success">✓<
返却形式: {"translated": "翻訳されたHTML"}/span>
{dict.consent.scopes[scope] ?? scope}
</li>
))}
</ul>
<button type="button" onClick={handleDeny}>Deny</button>
<button type="submit">Approve</button>
</form>
);
}
チームピッカーが重要な要素です。PaperLink はマルチテナントです。ユーザーは「My Freelance Business」と「Acme Corp.」の両方に所属しているかもしれません。各 MCP 接続は、ちょうど 1 つのチームにスコープが限定されます。両方のチームに対して AI アクセスを許可したい場合は、2 つの接続を作成します。これは意図的です。個人のチームには Claude にフルアクセスさせつつ、会社のチームには読み取り専用アクセスだけにしたいことがあるからです。
スコープは、i18n 辞書の人間が読めるラベルで表示されます。invoices:read は「請求書と見積もりを表示」として表示されます。accounting:write は「取引を作成および変更」として表示されます。ユーザーは、何を許可しているのかを理解できます。
ステップ 4: 認可コード
ユーザーが Approve をクリックすると、サーバーアクションが認可コードを作成します。
async execute(input: CreateAuthorizationCodeInput): Promise<Result<{ code: string }>> {
// ユーザーが選択したチームのアクティブなメンバーであることを確認する
const member = await this.teamMemberRepository.findByTeamAndUser(
input.teamId,
input.userId
);
if (!member?.isActive()) {
return Result.Forbidden(McpPermissionErrors.teamAccessDenied);
}
// 256 ビットのコード(2 つの UUID を連結)
const code = crypto.randomUUID() + crypto.randomUUID();
// 有効なスコープだけを通す。不正なものは黙って破棄する
const validScopes = input.scopes.filter((s): s is McpScope =>
Object.values(McpScope).includes(s as McpScope)
);
const entity = McpAuthorizationCodeEntity.create({
code,
userId: input.userId,
teamId: input.teamId,
teamRole: member.getRole(), // OWNER、ADMIN、MANAGER、MEMBER
codeChallenge: input.codeChallenge,
codeChallengeMethod: input.codeChallengeMethod,
scopes: validScopes,
// expiresAt: now + 10 分(自動で設定される)
});
await this.mcpAuthorizationCodeRepository.save(entity);
return Result.Success({ code });
}
認可コードは、生のデータベースレコードではなくドメインエンティティです。McpAuthorizationCodeEntity.create() はすべての入力を検証し、10 分間の有効期限を自動的に設定します。PKCE のチャレンジは、次のステップでの検証のためにコードと一緒に保存されます。
注目すべきセキュリティ上の詳細は 2 つです。
無効なスコープは黙って破棄され、拒否されない。 クライアントが invoices:read admin:superpower を要求した場合、コードは invoices:read だけで作成されます。これにより、認識できないスコープを原因に不正な挙動のクライアントがフロー全体を止めてしまうことを防ぎます。
チームのメンバーシップは、同意の表示だけでなくコード作成時に検証されます。 同意画面の表示後に「承認(Approve)」をクリックするまでの間に、ユーザーがチームから削除されるという競合状態(レースコンディション)については、ここで処理します。
Step 5: トークン交換
クライアントが認可コードをトークンと交換します:
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=<256-bit-code>&
code_verifier=<PKCE-verifier>&
redirect_uri=http://localhost:5555/callback&
client_id=claude-desktop
この交換ユースケースでは、5つの検証を実行します:
async execute(input: ExchangeCodeForTokenInput): Promise<Result<TokenResponse>> {
// 1. コードを1回の操作で検索してDELETE(ワンタイム利用)
const authCode = await this.mcpAuthorizationCodeRepository
.findByCodeAndDelete(input.code);
if (!authCode) return Result.Error('invalid_grant');
// 2. 10分間の有効期限ウィンドウを確認
if (authCode.isExpired()) return Result.Error('invalid_grant');
// 3. PKCE検証(タイミングセーフな比較)
const pkceValid = await authCode.verifyCodeChallenge(input.codeVerifier);
if (!pkceValid) return Result.Error('invalid_grant');
// 4. リダイレクトURIは完全一致でなければならない
if (authCode.getRedirectUri() !== input.redirectUri) {
return Result.Error('invalid_grant'}
});
await this.mcpAccessTokenRepository.save(tokenEntity);
return Result.Success({
accessToken, // 生の値 - 存在するのはこのときだけ
refreshToken,
expiresIn: ACCESS_TOKEN_EXPIRES_IN_SECONDS,
tokenType: 'Bearer',
});
}
ルートハンドラは、camelCase を OAuth の標準である snake_case のレスポンスへ変換します:
const token = result.value!;
return NextResponse.json({
access_token: token.accessToken,
token_type: token.tokenType,
expires_in: token.expiresIn,
refresh_token: token.refreshToken,
});
ここでの重要なセキュリティ上の判断:トークンは保存前にハッシュ化されます。 データベースには SHA-256(token) が格納され、生のトークンは格納されません。誰かがデータベースを持ち出しても、動作するトークンへ復元できないハッシュが得られるだけです。生のトークンはクライアントに対してちょうど1回だけ返され、サーバー側には保存されません。
これは GitHub がパーソナル アクセス トークンに使っているのと同じパターンです。この用途では JWT よりも安全です。ブロックリストを維持しなくても、トークンを個別に失効(リボーク)できるためです。データベース内のハッシュを見つけて削除するだけで完了します。
Step 6: すべてのリクエストでのトークン検証
すべての MCP リクエストは検証フローに到達します:
async function handleMcpRequest(request: Request): Promise<Response> {
const authInfo = await verifyBearerToken(request);
if (!authInfo) {
return withMcpCors(
new Response(
JSON.stringify({
error: 'invalid_token',
error_description: 'No authorization provided',
}),
{
status: 401,
headers: {
'Content-Type': 'application/json',
'WWW-Authenticate':
'Bearer resource_metadata="https://mcp.paperlink.online/.well-known/oauth-protected-resource"',
},
}
)
);
}
const server = createMcpServer(authInfo);
const transport = new WebStandardStreamableHTTPServerTransport({});
await server.connect(transport);
return withMcpCors(await transport.handleRequest(request));
}
verifyBearerToken 関数は次の3つのチェックを行います:
async execute(bearerToken: string): Promise<McpAuthInfoDto | null> {
// 1. 受け取ったトークンをハッシュ化して照合する
const tokenHash = await sha256Hash(bearerToken);
const token = await this.mcpAccessTokenRepository.findByTokenHash(tokenHash);
if (!token) return null;
返却形式: {"translated": "翻訳されたHTML"}// 2. 有効期限と失効(revocation)を確認する
if (token.isExpired() || token.isRevoked()) return null;
// 3. 有効なメンバーかの確認 - ユーザーはこのチームでまだアクティブか?
const member = await this.teamMemberRepository.findByTeamAndUser(
token.getTeamId(),
token.getUserId()
);
if (!member?.isActive()) return null;
return {
userId: token.getUserId(),
teamId: token.getTeamId(),
teamRole: member.getRole(),
scopes: token.getScopes(),
};
}
チェック #3 は、ほとんどのOAuth実装がスキップするものです。チーム管理者がユーザーを削除した場合、そのMCPトークンはトークンの有効期限が切れるまで1時間待つのではなく、即座に機能しなくなるべきです。リクエストのたびにチームのメンバーシップを確認します。データベースクエリを1回余分に実行するだけですが、「チームから削除する」が実際にアクセスを取り消すことを意味します。
返されるMcpAuthInfoDtoは軽量なオブジェクトで、すべてのツールハンドラに渡されます:
interface McpAuthInfoDto {
userId: string;
teamId: string;
teamRole: string; // OWNER、ADMIN、MANAGER、MEMBER
scopes: McpScope[]; // [McpScope.INVOICES_READ, McpScope.ACCOUNTING_WRITE]
}
パスワードも、トークンも、ハッシュもありません。必要なのは、ツールが使うアイデンティティと権限だけです。
ステップ 7:ツールにおけるスコープ強制
すべてのツールは、実行する前に必要なスコープを確認します:
export const registerAccountingReadTools: ToolRegistrar = (server, authInfo) => {
server.registerTool(
'list-invoices',
{
title: 'List Invoices',
description: '認証済みチームの請求書を一覧表示します。',
inputSchema: z.object({
status: z.string().optional(),
clientName: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
offset: z.coerce.number().int().min(0).optional(),
}),
annotations: { readOnlyHint: true },
},
async (params) => {
if (!authInfo.scopes.includes('invoices:read')) {
return {
content: [{ type: 'text', text: 'スコープが不足しています - invoices:read が必要です。' }],
isError: true,
};
}const useCase = mcpUseCases.getListInvoicesViaMcpUseCase();
const result = await useCase.execute({
teamId: authInfo.teamId, // 常に認証から取得し、パラメータからは取得しない
status: params.status,
clientName: params.clientName,
limit: params.limit,
offset: params.offset,
});
if (!result.isSuccess) {
return {
content: [{ type: 'text', text: result.errors.join(', ') }],
isError: true,
};
}
return {
content: [
{ type: 'text', text: `${result.value.length} 件の請求書が見つかりました。` },
{ type: 'text', text: JSON.stringify(result.value, null, 2) },
],
};
}
);
};
AIのパラメータから決して来ないものが2つあります。teamIdとuserIdです。これらは常にauthInfoから取得します。authInfoは検証済みトークンから設定されます。たとえAIがツール呼び出しでteamId: "someone-elses-team"を送ってきても、それは無視されます。認証コンテキストが勝ちます。
これは、Webアプリにおける「クライアントを信用しない」という考え方と同じ原則で、AIクライアントにも適用します。AIはクライアントです。パラメータを送ります。それを検証はしますが、アイデンティティは常にトークンから取得します。
トークンの更新
アクセストークンは1時間で期限切れになります。クライアントはユーザー操作なしで更新します:
async execute(input: { refreshToken: string }): Promise<Result<TokenResponse>> {
const refreshTokenHash = await sha256Hash(input.refreshToken);
const existingToken =
await this.mcpAccessTokenRepository.findByRefreshTokenHash(refreshTokenHash);
if (!existingToken) return Result.Error('invalid_grant');
if (existingToken.isRefreshExpired() || existingToken.isRevoked()) {
return Result.Error('invalid_grant');
}
// 同じスコープで新しいペアを発行する
const newAccessToken = generateSecureToken();
const newRefreshToken = generateSecureToken();
const [newTokenHash, newRefreshTokenHash] = await Promise.all([
sha256Hash(newAccessToken),
sha256Hash(newRefreshToken),
]);
返却形式: {"translated": "翻訳されたHTML"}const now = Date.now();
const newTokenEntity = McpAccessTokenEntity.create({
userId: existingToken.getUserId(),
teamId: existingToken.getTeamId(),
teamRole: existingToken.getTeamRole(),
tokenHash: newTokenHash,
refreshTokenHash: newRefreshTokenHash,
scopes: existingToken.getScopes(),
clientName: existingToken.getClientName(),
expiresAt: new Date(now + ACCESS_TOKEN_TTL_MS),
refreshExpiresAt: new Date(now + REFRESH_TOKEN_TTL_MS),
});
await this.mcpAccessTokenRepository.save(newTokenEntity);
// 古いトークンペアを無効化(ローテーション - リプレイを防止)
await this.mcpAccessTokenRepository.revoke(existingToken.revoke());
return Result.Success({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresIn: ACCESS_TOKEN_EXPIRES_IN_SECONDS,
tokenType: 'Bearer',
});
}
私たちはリフレッシュトークンのローテーションを使用します。つまり、リフレッシュのたびに新しいリフレッシュトークンを発行し、古いものを無効化します。リフレッシュトークンが2回使用された場合、2回目の試行は失敗します。これは、トークンの盗難の可能性を示すサインです。
リフレッシュトークンの有効期限は30日です。その後、ユーザーはブラウザ経由で再認証します。これが、同意画面を見るのが唯一のタイミングです。
The scope model
ドメインと権限レベルによって整理された25個のスコープがあります。
invoices:read invoices:write invoices:delete
accounting:read accounting:write accounting:delete
companies:read companies:write companies:delete
clients:read clients:write clients:delete
products:read products:write products:delete
estimates:read estimates:write estimates:delete
sharing:read sharing:write
teams:read teams:write
billing:read
ai:read ai:write
パターンは{domain}:{level}です。3つのレベル(read、write、delete)があります。すべてのドメインが3つを持つわけではありません。billingは「サブスクリプションを作成する」ツールがないため読み取り専用です。
これは、投稿2のファイル構成に直接対応しています。accountingReadTools.tsはaccounting:readを確認します。accountingWriteTools.tsはaccounting:writeを確認します。ファイル名がスコープを教えてくれます。
一般的な接続では5〜10個のスコープが付与されます。フリーランサーが自分の個人用AIアシスタントを接続する場合、すべてを許可するかもしれません。共有AIを会社の管理者が接続する場合は、invoices:readとaccounting:readだけを許可することになります。AIはデータを見ることはできますが、何も変更できません。
Why remote is better than local for SaaS
| ローカル(stdio + APIキー) | リモート(HTTP + OAuth) | |
|---|---|---|
| セットアップ | JSON設定ファイルを編集 | コマンドを1つ実行 |
| セキュリティ | 完全アクセスの静的キー | スコープ付きトークン、有効期限は1時間 |
| マルチテナンシー | チームごとのキーを手動で管理 | 同意UIでチームを選択 |
| 無効化(Revocation) | 新しいキーを生成して設定を更新 | アプリ設定で「disconnect」をクリック |
| ユーザー同意 | なし - キーを貼り付けて祈るだけ | ブラウザベースのスコープ承認 |
| 複数デバイス | デバイスごとに設定ファイル | トークンがClaude.aiの各デバイス間で同期される |
| アップデート | サーバーのバイナリを再インストール/再ビルド | クライアント側の変更なしでサーバーを更新 |
| 依存関係 | Node.js + npm + バイナリ | なし(HTTPトランスポート) |
最後の行は、あなたが思う以上に重要です。ローカルMCPサーバーでは、ユーザーがNode.jsをインストールしておき、npxを実行し、プロセスを生かし続ける必要があります。一方で、私たちのリモートサーバーは単なるHTTPエンドポイントです。Claude Desktop、Claude.ai、ChatGPT、Cursor - これらはすべてURLで接続します。ローカル実行環境は不要です。
Security model summary
AIができること:
- 付与されたスコープの範囲内にある任意のツールを呼び出す
- 選択したチーム内のデータを読み取り、作成、更新、または削除する
- 1つの会話内で複数のツールを連鎖させる
AIができないこと:
- ユーザーが選択していないチームのデータにアクセスする
- 付与されたスコープを超える(読み取り専用の接続は読み取り専用のまま)
- 別のユーザーをなりすます(userIdは常にトークンから取得される)
- 期限切れまたは無効化されたトークンを使う
- ユーザーがチームから外された後にデータへアクセスする(ライブのメンバーシップ確認)
ユーザーが制御できること:
- どのチームにアクセスを許可するか
- どのスコープを承認するか
- いつ切断するか(アプリ設定から無効化する)
If you're building a remote MCP server
以下は、うまくいったこと/うまくいかなかったことを踏まえて私たちが推奨する内容です:
返却形式: {"translated": "翻訳されたHTML"}最初からPKCEを使いましょう。 MVPとしてシンプルなAPIキーを少し検討しました。でもよかったです。動いているサーバに対して後から認証を組み込むのはつらいですし、APIキーで接続したユーザーは結局のところ再認証が必要になります。
保存前にトークンをハッシュ化します。 これは一度だけのコスト(コード2行)で、データ侵害のシナリオ全体のカテゴリを丸ごと取り除けます。
リクエストのたびにチーム所属を確認します。 追加のデータベースクエリはその価値があります。「チームからユーザーを削除する」べきなのは「トークンの有効期限が切れるときに削除する」ではなく、「アクセスを即時に削除する」です。
ドメイン単位のスコープ命名を使います。 スケールします。estimatesドメインを追加したとき、estimates:read、estimates:write、estimates:deleteも追加し、それぞれのスコープがどこに属するかが一目で分かりました。
コンテンツブロックを2つ返します。 人が読める要約と、完全なJSONデータです。AIは要約をユーザーに表示し、JSONは後続の操作に利用します。
まず試してみましょう
claude mcp add --transport http paperlink https://mcp.paperlink.online/api/mcp/mcp
同意画面が表示されるので、チームを選び、約10秒で接続できます。承認した内容にちょうど一致する118のツールが利用できます。




