Files
accounted/extensions/general/mcp-server/index.ts
T
1185ab4294 fix(mcp): honest tool text and build-derived server version (#1923)
Tool text that lied to agents:
- gnubok_create_voucher pointed at gnubok_reverse_entry, which does not
  exist; the tool is gnubok_reverse_journal_entry. A scan of server.ts,
  skills/, prompts/ and structured-errors.ts found no other phantom names.
- gnubok_reverse_journal_entry said reversal_date defaults to today; the
  executor passes undefined and reverseEntry() uses the original entry
  date (same as the dashboard). Description now states that. No behaviour
  change.
- gnubok_get_vacation_balance promised an estimated semesterloneskuld in
  SEK but returned none. The tool now returns estimated_liability_sek
  using the same BFNAR 2016:10 day valuation as the year-close and the v1
  vacation-balance route (dayValueSek exported from semesterberedning),
  floored at zero for overdrawn balances. Descriptions trimmed so the
  tools/list payload stays under the 60.7K-token ceiling (60,696 after).
- gnubok_create_invoice said the invoice number is assigned at approval;
  it is assigned on send or mark-as-sent (ensureInvoiceNumber).
- gnubok_convert_invoice: "har redan makuleras" -> "har redan makulerats".
- lib/entitlements/keys.ts comment claimed bank_sync has no MCP tool while
  the map right below gates gnubok_connect_bank on it.

Version: MCP serverInfo.version, the extension version and /api/health all
hardcoded '1.0.0', so clients could not tell deploys apart. They now share
currentAppVersion() (commit SHA prefix inlined at build), resolved once at
module load so the definitions layer stays deterministic, with '1.0.0' as
the self-hosted fallback so Docker healthchecks keep a value. serverInfo is
not part of tools/list, so the catalog payload is unaffected by this part.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:35:15 +02:00

87 lines
3.5 KiB
TypeScript

import { NextResponse } from 'next/server'
import type { Extension } from '@/lib/extensions/types'
import { buildProtectedResourceMetadata } from '@/lib/auth/protected-resource-metadata'
import { handleMcpRequest, tools as mcpTools } from './server'
import { isForbiddenOrigin, forbiddenOriginResponse } from './origin-guard'
import { registerAgentTools } from '@/lib/agent/tools/registry'
import type { AgentTool } from '@/lib/agent/tools/types'
import { currentAppVersion } from '@/lib/reports/app-version'
// Make the same tool set available to the in-app chat agent. The chat loop
// (lib/agent/chat/*) dispatches against the core agentToolRegistry so it can
// stay decoupled from this extension's module path. Tools satisfy the
// AgentTool contract structurally: see lib/agent/tools/types.ts.
registerAgentTools(mcpTools as unknown as AgentTool[])
export const mcpServerExtension: Extension = {
id: 'mcp-server',
name: 'MCP Server',
// Build-derived so deploys are distinguishable; '1.0.0' when self-hosted
// without an inlined commit SHA. Same identifier as serverInfo.version.
version: currentAppVersion() ?? '1.0.0',
settingsPanel: {
label: 'MCP-server (API)',
path: '/settings/api',
},
apiRoutes: [
{
method: 'POST',
path: '/mcp',
skipAuth: true, // Auth handled via API key in the handler
handler: async (request: Request) => {
// MCP spec MUST: validate Origin (DNS-rebinding defense). See origin-guard.ts.
if (isForbiddenOrigin(request)) return forbiddenOriginResponse()
return handleMcpRequest(request)
},
},
// MCP Streamable HTTP also needs GET for SSE and DELETE for session termination
{
method: 'GET',
path: '/mcp',
skipAuth: true,
// This server is stateless and offers no server-initiated SSE stream, so
// the Streamable HTTP spec requires 405 Method Not Allowed here. Returning
// 401 (as we previously did) makes spec-compliant clients (Claude
// connector, Claude Desktop, Cursor) treat the SSE GET as an auth failure
// and retry-loop: refresh token → re-open GET → 401 → …: which storms
// the endpoint and churns OAuth key rotation. OAuth discovery is
// bootstrapped on the POST 401 (WWW-Authenticate), not here.
handler: async (request: Request) => {
if (isForbiddenOrigin(request)) return forbiddenOriginResponse()
return new Response('Method Not Allowed', {
status: 405,
headers: { Allow: 'POST, DELETE' },
})
},
},
{
method: 'DELETE',
path: '/mcp',
skipAuth: true,
// Stateless: no sessions to terminate
handler: async (request: Request) => {
if (isForbiddenOrigin(request)) return forbiddenOriginResponse()
return new Response(null, { status: 204 })
},
},
{
method: 'GET',
path: '/mcp/.well-known/oauth-protected-resource',
skipAuth: true,
// Endpoint-appended RFC 9728 discovery. Claude.ai's connector setup
// derives the metadata URL from the server URL and tries both the
// path-based root form and this one before any 401; a 404 here reads
// as "Authorization failed". Public by nature: it names the
// authorization server and nothing tenant-specific.
handler: async (request: Request) => {
if (isForbiddenOrigin(request)) return forbiddenOriginResponse()
return NextResponse.json(buildProtectedResourceMetadata(request))
},
},
],
eventHandlers: [],
}