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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 34 additions & 24 deletions ee/apps/den-api/src/mcp/admin-capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import type { CapabilityMatch } from "./search.js"
import { scoreText, tokenize } from "./search.js"
import { rankCapabilities } from "./ranking.js"
import type { CapabilityCandidate, CapabilityMatch } from "./ranking.js"
import { DEN_ADMIN_MCP_VERSION, registerAdminMcpTools } from "./admin-tools.js"

export const ADMIN_CAPABILITY_PREFIX = "admin:"
Expand Down Expand Up @@ -34,30 +34,40 @@ export function parseAdminCapabilityName(name: string): string | null {
return toolName.length > 0 ? toolName : null
}

export async function searchAdminCapabilities(query: string, limit = 5): Promise<CapabilityMatch[]> {
const queryTokens = tokenize(query)
const boundedLimit = Math.max(1, Math.min(20, Math.trunc(limit) || 5))
export async function listAdminCapabilityCandidates(): Promise<CapabilityCandidate[]> {
const { tools } = await withAdminClient((client) => client.listTools())

return tools
.map((tool) => ({
name: `${ADMIN_CAPABILITY_PREFIX}${tool.name}`,
method: "MCP",
path: "/mcp/admin",
score: scoreText(
tokenize(tool.name),
tokenize(tool.description ?? ""),
queryTokens,
["admin", "platform"],
),
summary: `[OpenWork Admin] ${tool.description ?? tool.name}`,
pathParams: [],
queryParams: [],
hasBody: Object.keys(tool.inputSchema.properties ?? {}).length > 0,
}))
.filter((match) => match.score > 0)
.sort((a, b) => (b.score - a.score) || a.name.localeCompare(b.name))
.slice(0, boundedLimit)
return tools.map((tool) => {
const summary = `[OpenWork Admin] ${tool.description ?? tool.name}`
return {
match: {
name: `${ADMIN_CAPABILITY_PREFIX}${tool.name}`,
method: "MCP",
path: "/mcp/admin",
summary,
pathParams: [],
queryParams: [],
hasBody: Object.keys(tool.inputSchema.properties ?? {}).length > 0,
source: "admin",
},
searchText: {
name: tool.name,
summary,
path: "/mcp/admin",
keywords: ["admin", "platform", "openwork"],
},
}
})
}

export async function searchAdminCapabilities(query: string, limit = 5): Promise<CapabilityMatch[]> {
return rankCapabilities(query, await listAdminCapabilityCandidates(), { limit })
}

export async function listAvailableAdminCapabilityCandidates(
platformAdmin: boolean,
): Promise<CapabilityCandidate[]> {
return platformAdmin ? listAdminCapabilityCandidates() : []
}

export async function searchAvailableAdminCapabilities(
Expand Down
133 changes: 87 additions & 46 deletions ee/apps/den-api/src/mcp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import { getMcpResourceContext, verifyMcpRequest } from "./auth.js"
import { invokeMcpOperation, normalizeToolBody, normalizeToolRecord } from "./invoke.js"
import { getCatalog, protectedResourceMetadata } from "./index.js"
import { preflightMcpJsonRpcRequest } from "./json-rpc-preflight.js"
import { compareCapabilityMatches, SEARCH_CAPABILITIES_TOOL_NAME, searchCapabilities, searchCapabilitySourceFilter, type CapabilityMatch } from "./search.js"
import { executeExternalCapability, externalMcpSearchCoverageHint, parseExternalCapabilityName, resolveMcpMemberIdentity, searchExternalCapabilities, type ExternalCapabilityExecuteResult } from "./external-capabilities.js"
import { executeMarketplaceCapability, parseMarketplaceCapabilityName, searchMarketplaceCapabilities, type MarketplaceCapabilityObjectType } from "./marketplace-capabilities.js"
import { executeSkillCapability, parseSkillCapabilityName, searchSkillCapabilities } from "./skill-capabilities.js"
import { buildRestCandidates, SEARCH_CAPABILITIES_TOOL_NAME, searchCapabilitySourceFilter, type CapabilityMatch } from "./search.js"
import { buildZeroResultSuggestions, parseQuery, rankCapabilities } from "./ranking.js"
import type { CapabilityCandidate } from "./ranking.js"
import { executeExternalCapability, EXTERNAL_MCP_SEARCH_MATCH_LIMIT, externalMcpSearchCoverageHint, parseExternalCapabilityName, resolveMcpMemberIdentity, searchExternalCapabilities, type ExternalCapabilityExecuteResult } from "./external-capabilities.js"
import { executeMarketplaceCapability, listMarketplaceCapabilityCandidates, parseMarketplaceCapabilityName, type MarketplaceCapabilityObjectType } from "./marketplace-capabilities.js"
import { executeSkillCapability, listSkillCapabilityCandidates, parseSkillCapabilityName } from "./skill-capabilities.js"
import { resolvePublicOrigin } from "../capability-sources/generic-oauth.js"
import { env } from "../env.js"
import { isPlatformAdminUserId } from "../middleware/admin.js"
import { executeAvailableAdminCapability, parseAdminCapabilityName, searchAvailableAdminCapabilities } from "./admin-capabilities.js"
import { executeAvailableAdminCapability, listAvailableAdminCapabilityCandidates, parseAdminCapabilityName } from "./admin-capabilities.js"

export const EXECUTE_CAPABILITY_TOOL_NAME = "execute_capability"
const searchCapabilityTypeSchema = z.enum(["all", "api", "admin", "mcp", "marketplace", "skills"])
Expand Down Expand Up @@ -85,6 +87,7 @@ const capabilityMatchOutputSchema = z.object({
pathParams: z.array(z.string()),
queryParams: z.array(z.string()),
hasBody: z.boolean(),
source: z.enum(["rest", "admin", "external_mcp", "marketplace", "skills"]).optional(),
kind: z.string().optional(),
status: z.string().optional(),
hint: z.string().optional(),
Expand All @@ -94,6 +97,7 @@ const capabilityMatchOutputSchema = z.object({
export const SEARCH_CAPABILITIES_OUTPUT_SCHEMA = z.object({
matches: z.array(capabilityMatchOutputSchema),
hint: z.string().optional(),
suggestions: z.array(z.string()).optional(),
})

export const AGENT_MCP_INSTRUCTIONS = [
Expand Down Expand Up @@ -133,18 +137,40 @@ export function externalCapabilityErrorToolResult(
}
}

export function capabilitySearchToolResult<T extends CapabilityMatch>(matches: T[], coverageHint?: string) {
export function capabilitySearchToolResult<T extends CapabilityMatch>(
matches: T[],
coverageHint?: string,
suggestions: string[] = [],
emptyHint = "No matches. Try broader or different keywords.",
) {
const hint = [
...(matches.length === 0 ? ["No matches. Try broader or different keywords."] : []),
...(matches.length === 0 ? [emptyHint] : []),
...(coverageHint ? [coverageHint] : []),
].join(" ")
const result = hint ? { matches, hint } : { matches }
const result = {
matches,
...(hint ? { hint } : {}),
...(matches.length === 0 && suggestions.length > 0 ? { suggestions } : {}),
}
return {
content: textContent(JSON.stringify(result, null, 2)),
structuredContent: result,
}
}

function candidateFromMatch(match: CapabilityMatch): CapabilityCandidate {
const { score: _score, ...matchFields } = match
return {
match: matchFields,
searchText: {
name: match.name,
summary: match.summary,
path: match.path,
keywords: [match.method, match.source ?? ""],
},
}
}

function unknownCapabilityText(name: string): string {
return JSON.stringify({
error: "unknown_capability",
Expand Down Expand Up @@ -321,49 +347,64 @@ export function registerAgentMcpRoutes<T extends { Variables: Record<string, unk
const boundedLimit = limit ?? 5
const sourceFilter = searchCapabilitySourceFilter(type)
const marketplaceObjectTypes = type === "skills" ? skillMarketplaceObjectTypes : undefined
const restMatches = sourceFilter.api ? searchCapabilities(catalog, query, boundedLimit) : []
const adminMatches = sourceFilter.admin
? await searchAvailableAdminCapabilities(await resolvePlatformAdmin(), query, boundedLimit)
: []
if (parseQuery(query).length === 0) {
return capabilitySearchToolResult(
[],
undefined,
[],
"Query contains only common words. Use a domain keyword, e.g. \"save memory\", \"list workers\", or \"create skill\".",
)
}
const restCandidates = sourceFilter.api ? buildRestCandidates(catalog) : []
// Merged in from each connected External MCP Connection's live
// tools/list (capability-sources/external-mcp-client.ts) — a
// Notion/Linear/Stripe/... connection an admin added in Den shows
// up here exactly like any native capability, ranked together.
let externalCoverageHint: string | undefined
const externalMatches = sourceFilter.mcp && externalMcpConnectionsEnabled
? await searchExternalCapabilities({
organizationId: principal.organizationId,
member: memberIdentity,
query,
redirectUriBase: resolvePublicOrigin(c.req.raw, env.apiPublicUrl),
limit: boundedLimit,
reportCoverage: (coverage) => {
externalCoverageHint = externalMcpSearchCoverageHint(coverage)
},
})
: []
const marketplaceMatches = sourceFilter.marketplace && externalMcpConnectionsEnabled
? await searchMarketplaceCapabilities({
organizationId: principal.organizationId,
member: memberIdentity,
objectTypes: marketplaceObjectTypes,
query,
limit: boundedLimit,
enabled: externalMcpConnectionsEnabled,
})
: []
const skillMatches = sourceFilter.skills
? await searchSkillCapabilities({
organizationId: principal.organizationId,
member: memberIdentity,
query,
limit: boundedLimit,
})
: []
const matches = [...restMatches, ...adminMatches, ...externalMatches, ...marketplaceMatches, ...skillMatches]
.sort(compareCapabilityMatches)
.slice(0, boundedLimit)
return capabilitySearchToolResult(matches, externalCoverageHint)
const [adminCandidates, externalMatches, marketplaceCandidates, skillCandidates] = await Promise.all([
sourceFilter.admin
? resolvePlatformAdmin().then(listAvailableAdminCapabilityCandidates)
: Promise.resolve([]),
sourceFilter.mcp && externalMcpConnectionsEnabled
? searchExternalCapabilities({
organizationId: principal.organizationId,
member: memberIdentity,
query,
redirectUriBase: resolvePublicOrigin(c.req.raw, env.apiPublicUrl),
limit: EXTERNAL_MCP_SEARCH_MATCH_LIMIT,
reportCoverage: (coverage) => {
externalCoverageHint = externalMcpSearchCoverageHint(coverage)
},
})
: Promise.resolve([]),
sourceFilter.marketplace && externalMcpConnectionsEnabled
? listMarketplaceCapabilityCandidates({
organizationId: principal.organizationId,
member: memberIdentity,
objectTypes: marketplaceObjectTypes,
enabled: externalMcpConnectionsEnabled,
})
: Promise.resolve([]),
sourceFilter.skills
? listSkillCapabilityCandidates({
organizationId: principal.organizationId,
member: memberIdentity,
})
: Promise.resolve([]),
])
const candidates: CapabilityCandidate[] = [
...restCandidates,
...adminCandidates,
...externalMatches.map(candidateFromMatch),
...marketplaceCandidates,
...skillCandidates,
]
const matches = rankCapabilities(query, candidates, { limit: boundedLimit })
return capabilitySearchToolResult(
matches,
externalCoverageHint,
buildZeroResultSuggestions(query, candidates),
)
},
)

Expand Down
25 changes: 23 additions & 2 deletions ee/apps/den-api/src/mcp/external-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
import { getConnectedAccount } from "../capability-sources/oauth-credentials.js"
import { db } from "../db.js"
import { listTeamsForMember } from "../orgs.js"
import { parseQuery, rankCapabilities } from "./ranking.js"
import type { CapabilityCandidate } from "./ranking.js"
import { compareCapabilityMatches, tokenize } from "./search.js"
import type { CapabilityMatch } from "./search.js"

Expand Down Expand Up @@ -170,6 +172,21 @@ export type ExternalConnectionStatus = {
diagnostic?: ExternalMcpDiagnostic
}

function candidateFromExternalMatch(
match: ExternalCapabilityMatch,
): CapabilityCandidate<Omit<ExternalCapabilityMatch, "score">> {
const { score: _score, ...matchFields } = match
return {
match: matchFields,
searchText: {
name: match.connectionStatus?.connectionName ?? match.name,
summary: match.summary,
path: match.path,
keywords: [match.method, match.connectionStatus?.connectionName ?? ""],
},
}
}

const ERROR_MESSAGE_LIMIT = 300
const LIVE_PROBE_HINT = "This is a live probe, not a cached result — repeating the same search without changing anything will return the same error."
const INVALID_REFRESH_TOKEN_PATTERN = /\binvalid[ _-]?refresh[ _-]?token\b/i
Expand Down Expand Up @@ -408,6 +425,7 @@ function statusMatch(input: {
pathParams: [],
queryParams: [],
hasBody: false,
source: "external_mcp",
kind: "connection_status",
status: input.status,
hint: input.hint,
Expand Down Expand Up @@ -610,6 +628,7 @@ async function probeExternalMcpConnection(input: {
pathParams: [],
queryParams: [],
hasBody: true,
source: "external_mcp",
})
}
return matches
Expand All @@ -630,7 +649,8 @@ export async function searchExternalCapabilities(input: {
reportCoverage?: (coverage: ExternalMcpSearchCoverage) => void
}): Promise<ExternalCapabilityMatch[]> {
if (!input.member) return []
const queryTokens = tokenize(input.query)
const queryTokens = [...new Set(parseQuery(input.query).flatMap((concept) =>
concept.terms.map((term) => term.token)))]
if (queryTokens.length === 0) return []
const requestedLimit = input.limit ?? 5
if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) return []
Expand All @@ -647,7 +667,7 @@ export async function searchExternalCapabilities(input: {
probedConnections: selectedConnections.length,
truncated: selectedConnections.length < connections.length,
})
return await collectBoundedExternalMcpSearchMatches({
const preliminaryMatches = await collectBoundedExternalMcpSearchMatches({
connections: selectedConnections,
deadline,
limit,
Expand All @@ -660,6 +680,7 @@ export async function searchExternalCapabilities(input: {
deadline: sharedDeadline,
}),
})
return rankCapabilities(input.query, preliminaryMatches.map(candidateFromExternalMatch), { limit })
}

export type ExternalCapabilityExecuteResult =
Expand Down
Loading
Loading